From 5387cbaca4fea6bbf055e7b284821699546eb7e9 Mon Sep 17 00:00:00 2001 From: zuoxiao <470321431@qq.com> Date: 星期三, 22 一月 2025 14:56:53 +0800 Subject: [PATCH] 1.视频播放兼容高低版本手机 2.优化工单icon 3.修复修改经纬度因js中添加了减少调用频率的代码导致的修改经纬度bug 4.修复了切换界面webView白屏的bug --- app/src/main/java/com/dayu/pipirrapp/utils/WebViewUtils.java | 3 app/src/main/java/com/dayu/pipirrapp/net/Constants.java | 5 library/.gitignore | 11 library/src/main/java/cc/shinichi/library/view/ImagePreviewActivity.kt | 755 +++ library/src/main/res/drawable-xxhdpi/icon_video_stop.png | 0 library/build.gradle | 42 library/src/main/java/cc/shinichi/library/tool/common/DeviceUtil.kt | 54 library/src/main/res/layout/sh_item_photoview.xml | 45 library/src/main/res/values/strings.xml | 10 library/src/main/java/cc/shinichi/library/glide/FileTarget.kt | 44 library/src/main/java/cc/shinichi/library/view/subsampling/ImageViewState.java | 43 library/src/main/res/anim/fade_in.xml | 8 library/src/main/java/cc/shinichi/library/view/listener/OnBigImageClickListener.kt | 18 library/src/main/java/cc/shinichi/library/glide/SSLSocketClient.kt | 63 library/src/main/AndroidManifest.xml | 16 library/src/main/java/cc/shinichi/library/glide/ImageLoader.kt | 55 library/src/main/java/cc/shinichi/library/tool/image/ImageUtil.kt | 410 ++ library/src/main/res/anim/scale_out.xml | 16 library/src/main/java/cc/shinichi/library/GlobalContext.kt | 39 library/src/main/java/cc/shinichi/library/tool/common/HttpUtil.kt | 66 library/src/main/java/cc/shinichi/library/view/photoview/OnViewDragListener.java | 16 library/src/main/res/values/ids.xml | 5 library/src/main/java/cc/shinichi/library/view/listener/OnFinishListener.kt | 6 library/src/main/java/cc/shinichi/library/view/listener/SimpleOnImageEventListener.kt | 18 library/src/main/res/drawable-xxhdpi/icon_download_new.png | 0 library/src/main/java/cc/shinichi/library/tool/common/HandlerHolder.kt | 25 library/src/main/java/cc/shinichi/library/view/ImagePreviewAdapter.kt | 25 library/src/main/java/cc/shinichi/library/view/listener/OnPageDragListener.kt | 17 library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageDecoder.java | 108 library/src/main/java/cc/shinichi/library/ImagePreview.kt | 594 ++ library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageDecoder.java | 32 app/proguard-rules.pro | 5 library/src/main/java/cc/shinichi/library/view/listener/OnCustomLayoutCallback.kt | 9 library/src/main/java/cc/shinichi/library/tool/common/ToastUtil.kt | 33 library/src/main/java/cc/shinichi/library/view/helper/DragCloseView.java | 255 + library/src/main/java/cc/shinichi/library/view/subsampling/decoder/CompatDecoderFactory.java | 52 library/src/main/java/cc/shinichi/library/view/photoview/CustomGestureDetector.java | 198 library/src/main/java/cc/shinichi/library/view/subsampling/ImageSource.java | 273 + library/src/main/java/cc/shinichi/library/view/photoview/OnScaleChangedListener.java | 16 library/src/main/java/cc/shinichi/library/view/listener/OnPageFinishListener.kt | 14 library/src/main/java/cc/shinichi/library/tool/common/SLog.kt | 99 library/src/main/res/anim/scale_in.xml | 16 library/src/main/java/cc/shinichi/library/tool/file/FileUtil.kt | 162 library/src/main/java/cc/shinichi/library/view/photoview/OnSingleFlingListener.java | 21 library/src/main/java/cc/shinichi/library/view/listener/OnBigImageLongClickListener.kt | 18 library/src/main/java/cc/shinichi/library/InitProvider.kt | 75 library/src/main/res/drawable-xhdpi/load_failed.png | 0 library/src/main/java/cc/shinichi/library/view/photoview/Util.java | 36 library/src/main/res/drawable-xhdpi/icon_download_new.png | 0 library/src/main/java/cc/shinichi/library/glide/cache/DataCacheKey.kt | 35 library/src/main/java/cc/shinichi/library/view/subsampling/decoder/DecoderFactory.java | 26 library/src/main/java/cc/shinichi/library/tool/common/UIUtil.kt | 32 library/src/main/java/cc/shinichi/library/tool/common/NetworkUtil.kt | 25 library/src/main/java/cc/shinichi/library/view/photoview/PhotoView.java | 286 + library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageRegionDecoder.java | 165 app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java | 71 library/src/main/res/layout/sh_media_controller.xml | 51 library/src/main/res/drawable-xxhdpi/icon_change_orientation.png | 0 library/src/main/res/drawable-xxhdpi/ic_action_close.png | 0 library/src/main/java/cc/shinichi/library/view/photoview/OnPhotoTapListener.java | 22 library/src/main/res/drawable-xxhdpi/icon_video_play.png | 0 library/src/main/res/drawable/gray_circle_bg.xml | 5 library/src/main/java/cc/shinichi/library/view/listener/OnDownloadListener.kt | 16 library/src/main/java/cc/shinichi/library/view/ImagePreviewFragment.kt | 934 ++++ library/src/main/java/cc/shinichi/library/bean/ImageInfo.kt | 27 app/build.gradle | 14 library/src/main/res/drawable-xhdpi/icon_change_orientation.png | 0 library/src/main/java/cc/shinichi/library/view/photoview/PhotoViewAttacher.java | 789 +++ library/src/main/res/drawable-xxhdpi/load_failed.png | 0 library/src/main/java/cc/shinichi/library/glide/cache/SafeKeyGenerator.kt | 36 library/src/main/java/cc/shinichi/library/tool/file/SingleMediaScanner.kt | 39 library/src/main/java/cc/shinichi/library/glide/progress/ProgressResponseBody.kt | 62 library/src/main/java/cc/shinichi/library/view/subsampling/SubsamplingScaleImageView.java | 3381 ++++++++++++++++ library/src/main/java/cc/shinichi/library/tool/image/DownloadUtil.kt | 287 + library/src/main/java/cc/shinichi/library/view/photoview/OnViewTapListener.java | 16 library/src/main/res/layout/sh_layout_preview.xml | 107 library/src/main/res/values/style.xml | 10 app/src/main/res/drawable/bottom_order_black.xml | 33 app/src/main/java/com/dayu/pipirrapp/activity/MainActivity.java | 1 library/src/main/java/cc/shinichi/library/glide/progress/ProgressManager.kt | 70 library/src/main/res/drawable-xhdpi/ic_action_close.png | 0 library/src/main/java/cc/shinichi/library/glide/progress/ProgressLibraryGlideModule.kt | 27 app/src/main/res/drawable/bottom_order_white.xml | 61 app/src/main/assets/js/map.js | 4 library/src/main/java/cc/shinichi/library/view/HackyViewPager.kt | 37 library/src/main/res/drawable-xhdpi/icon_video_stop.png | 0 library/src/main/java/cc/shinichi/library/view/nine/ViewHelper.java | 293 + library/src/main/java/cc/shinichi/library/view/photoview/OnMatrixChangedListener.java | 18 library/src/main/java/cc/shinichi/library/view/photoview/OnGestureListener.java | 25 library/src/main/res/drawable/shape_indicator_bg.xml | 8 library/src/main/res/values-en-rUS/strings.xml | 10 library/src/main/java/cc/shinichi/library/glide/progress/OnProgressListener.kt | 15 library/src/main/res/drawable-xhdpi/icon_video_play.png | 0 library/src/main/java/cc/shinichi/library/view/listener/OnOriginProgressListener.kt | 22 app/src/main/java/com/dayu/pipirrapp/activity/OrderDetailActivity.java | 4 settings.gradle | 2 library/src/main/java/cc/shinichi/library/tool/common/PhoneUtil.kt | 66 library/src/main/java/cc/shinichi/library/view/listener/OnBigImagePageChangeListener.kt | 43 library/src/main/java/cc/shinichi/library/view/listener/OnDownloadClickListener.kt | 26 library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageRegionDecoder.java | 67 library/src/main/java/cc/shinichi/library/view/photoview/OnOutsidePhotoTapListener.java | 14 library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaPooledImageRegionDecoder.java | 468 ++ library/src/main/java/cc/shinichi/library/view/nine/AnimatorProxy.java | 345 + app/src/main/java/com/dayu/pipirrapp/activity/IssueDetailActivity.java | 4 library/proguard-rules.pro | 25 library/src/main/res/drawable/gray_square_circle_bg_white_stroke.xml | 8 library/src/main/res/values/attrs.xml | 13 library/src/main/java/cc/shinichi/library/view/photoview/Compat.java | 33 library/src/main/res/anim/fade_out.xml | 8 library/src/main/res/layout/sh_default_progress_layout.xml | 22 110 files changed, 11,980 insertions(+), 59 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e727076..fd2619d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -89,7 +89,7 @@ } dependencies { - + implementation project(':library') implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.8.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' @@ -152,12 +152,12 @@ // 鍥剧墖鍘嬬缉 (鎸夐渶寮曞叆) implementation 'io.github.lucksiege:compress:v3.11.2' - // 鍥剧墖鏌ョ湅 - implementation('com.github.SherlockGougou:BigImageViewPager:androidx-8.1.3') { - exclude group: 'androidx.appcompat'; - exclude group: 'com.google.android.material'; - exclude group: 'androidx.core'; - } +// // 鍥剧墖鏌ョ湅 +// implementation('com.github.SherlockGougou:BigImageViewPager:androidx-8.1.3') { +// exclude group: 'androidx.appcompat'; +// exclude group: 'com.google.android.material'; +// exclude group: 'androidx.core'; +// } implementation "androidx.media3:media3-exoplayer:1.4.1" implementation "androidx.media3:media3-exoplayer-dash:1.4.1" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2c6288f..6965b36 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -152,4 +152,9 @@ -keep class com.luck.lib.camerax.** { *; } -dontwarn com.yalantis.ucrop** -keep class com.yalantis.ucrop** { *; } +-keep interface com.yalantis.ucrop** { *; } +-keep class com.luck.picture.lib.** { *; } +-keep class com.luck.lib.camerax.** { *; } +-dontwarn com.yalantis.ucrop** +-keep class com.yalantis.ucrop** { *; } -keep interface com.yalantis.ucrop** { *; } \ No newline at end of file diff --git a/app/src/main/assets/js/map.js b/app/src/main/assets/js/map.js index 8238d7f..ba614dd 100644 --- a/app/src/main/assets/js/map.js +++ b/app/src/main/assets/js/map.js @@ -475,10 +475,10 @@ function mapMoveEnd(e) { - debounce(() => { + const center = e.target.getCenter(); window.Android.refreshCenter(center.getLng(), center.getLat()); - }, 300); + } // 娣诲姞闃叉姈鍑芥暟 diff --git a/app/src/main/java/com/dayu/pipirrapp/activity/IssueDetailActivity.java b/app/src/main/java/com/dayu/pipirrapp/activity/IssueDetailActivity.java index 78ad6f4..c5c10fb 100644 --- a/app/src/main/java/com/dayu/pipirrapp/activity/IssueDetailActivity.java +++ b/app/src/main/java/com/dayu/pipirrapp/activity/IssueDetailActivity.java @@ -102,7 +102,7 @@ for (ImageResult imageResult : t.getContent().getImages()) { ImageBean imageBean = new ImageBean(); imageBean.setId(imageResult.getId()); - imageBean.setWebPath(imageResult.getWebPath()); + imageBean.setWebPath(imageResult.getWebPathZip()); imageBean.setType(UplodFileState.IMG_TYPE); images.add(imageBean); ImageInfo info = new ImageInfo(); @@ -116,7 +116,7 @@ for (ImageResult imageResult : t.getContent().getVideos()) { ImageBean imageBean = new ImageBean(); imageBean.setId(imageResult.getId()); - imageBean.setWebPath(imageResult.getWebPath()); + imageBean.setWebPath(imageResult.getWebPathZip()); imageBean.setType(UplodFileState.VIDEO_TYPE); images.add(imageBean); ImageInfo info = new ImageInfo(); diff --git a/app/src/main/java/com/dayu/pipirrapp/activity/MainActivity.java b/app/src/main/java/com/dayu/pipirrapp/activity/MainActivity.java index f421db1..641aecb 100644 --- a/app/src/main/java/com/dayu/pipirrapp/activity/MainActivity.java +++ b/app/src/main/java/com/dayu/pipirrapp/activity/MainActivity.java @@ -146,7 +146,6 @@ binding.viewPager.setCurrentItem(1, false); // 榛樿鏄剧ず鍦板浘椤� binding.viewPager.setOffscreenPageLimit(fragments.size()); binding.viewPager.setUserInputEnabled(false); - } @Override diff --git a/app/src/main/java/com/dayu/pipirrapp/activity/OrderDetailActivity.java b/app/src/main/java/com/dayu/pipirrapp/activity/OrderDetailActivity.java index b498d0f..2a1fd15 100644 --- a/app/src/main/java/com/dayu/pipirrapp/activity/OrderDetailActivity.java +++ b/app/src/main/java/com/dayu/pipirrapp/activity/OrderDetailActivity.java @@ -228,7 +228,7 @@ for (ImageResult imageResult:t.getContent().getImages()){ ImageBean imageBean = new ImageBean(); imageBean.setId(imageResult.getId()); - imageBean.setWebPath(imageResult.getWebPath()); + imageBean.setWebPath(imageResult.getWebPathZip()); imageBean.setType(UplodFileState.IMG_TYPE); images.add(imageBean); ImageInfo info = new ImageInfo(); @@ -242,7 +242,7 @@ for (ImageResult imageResult:t.getContent().getVideos()){ ImageBean imageBean = new ImageBean(); imageBean.setId(imageResult.getId()); - imageBean.setWebPath(imageResult.getWebPath()); + imageBean.setWebPath(imageResult.getWebPathZip()); imageBean.setType(UplodFileState.VIDEO_TYPE); images.add(imageBean); ImageInfo info = new ImageInfo(); diff --git a/app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java b/app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java index c414864..39ec637 100644 --- a/app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java +++ b/app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java @@ -114,6 +114,8 @@ public double centerLng; public double centerLat; + MarkerBean mMarkerBean; + @Override public void onAttach(@NonNull Context context) { super.onAttach(context); @@ -125,6 +127,7 @@ @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setRetainInstance(true); Log.i(TAG, "onCreate"); mInspectionState = SharedPreferencesHelper.getInstance(this.getContext()).get(CommonKeyName.inspectionState, 0); } @@ -253,7 +256,6 @@ } } - /** * 鑾峰彇鍙栨按鍙e垪琛� */ @@ -311,7 +313,11 @@ //宸℃鎸夐挳 binding.inspectButton.setOnClickListener(v -> { if (XXPermissions.isGranted(MapFragment.this.getContext(), Permission.ACCESS_BACKGROUND_LOCATION)) { - chageInspecState(InspectionUtils.STAT_INSPECTION_ONCLICK); + new ConfirmDialog(MapFragment.this.getActivity(), (confirmDialog, v1) -> { + chageInspecState(InspectionUtils.STAT_INSPECTION_ONCLICK); + confirmDialog.dismiss(); + }).show(); + } else { TipUtil.show(MapFragment.this.getActivity(), "宸℃瀹氫綅闇�瑕佹偍閫夋嫨\"濮嬬粓鍏佽\"瀹氫綅淇℃伅锛屽惁鍒欐棤娉曞贰妫�銆�", new TipUtil.TipListener() { @Override @@ -739,6 +745,8 @@ }); binding.pointCenterImg.setVisibility(View.GONE); binding.pointRL.setVisibility(View.GONE); + mMarkerBean.setLat(lat); + mMarkerBean.setLng(lng); binding.lng.setText(lng); binding.lat.setText(lat); mWebView.evaluateJavascript("javascript:cancelPin()", value -> { @@ -770,20 +778,20 @@ public void onNext(BaseResponse<MarkerResult> t) { if (t.isSuccess()) { MarkerResult result = t.getContent(); - MarkerBean markerBean = new MarkerBean(); - markerBean.setId(result.getId()); - markerBean.setLng(result.getLng()); - markerBean.setLat(result.getLat()); - markerBean.setBlockId(result.getBlockId()); - markerBean.setName(result.getName()); - markerBean.setRemarks(result.getRemarks()); - markerBean.setTownId(result.getTownId()); - markerBean.setVillageId(result.getVillageId()); - markerBean.setCountyId(result.getCountyId()); - markerBean.setAddress(result.getAddress()); - markerBean.setBlockName(result.getBlockName()); - markerBean.setDivideId(result.getDivideId()); - showMarker(markerBean); + mMarkerBean = new MarkerBean(); + mMarkerBean.setId(result.getId()); + mMarkerBean.setLng(result.getLng()); + mMarkerBean.setLat(result.getLat()); + mMarkerBean.setBlockId(result.getBlockId()); + mMarkerBean.setName(result.getName()); + mMarkerBean.setRemarks(result.getRemarks()); + mMarkerBean.setTownId(result.getTownId()); + mMarkerBean.setVillageId(result.getVillageId()); + mMarkerBean.setCountyId(result.getCountyId()); + mMarkerBean.setAddress(result.getAddress()); + mMarkerBean.setBlockName(result.getBlockName()); + mMarkerBean.setDivideId(result.getDivideId()); + showMarker(mMarkerBean); } else { ToastUtil.showToast(MapFragment.this.getContext(), t.getMsg()); } @@ -1024,5 +1032,36 @@ public void onDestroy() { super.onDestroy(); LiveEventBus.get(CommonKeyName.locationData).removeObserver(locationObserver); + if (mWebView != null) { + mWebView.destroy(); + } + } + + @Override + public void onResume() { + super.onResume(); + mWebView.onResume(); + mWebView.resumeTimers(); + } + + @Override + public void onPause() { + super.onPause(); + mWebView.onPause(); + mWebView.pauseTimers(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mWebView.saveState(outState); + } + + @Override + public void onViewStateRestored(@Nullable Bundle savedInstanceState) { + super.onViewStateRestored(savedInstanceState); + if (savedInstanceState != null) { + mWebView.restoreState(savedInstanceState); + } } } diff --git a/app/src/main/java/com/dayu/pipirrapp/net/Constants.java b/app/src/main/java/com/dayu/pipirrapp/net/Constants.java index 9b4d3d1..b892373 100644 --- a/app/src/main/java/com/dayu/pipirrapp/net/Constants.java +++ b/app/src/main/java/com/dayu/pipirrapp/net/Constants.java @@ -7,9 +7,10 @@ */ public class Constants { // public static final String BASE_URL = "http://192.168.10.52:8088"; - public static final String BASE_URL = "https://no253541tf71.vicp.fun"; +// public static final String BASE_URL = "https://no253541tf71.vicp.fun"; + public static final String BASE_URL = "http://192.168.40.166:54321"; //public static final String BASE_URL = "http://fve2iz.natappfree.cc"; - public static final String BASE_UPLOAD_FILE_URL = "https://no253541tf71.vicp.fun"; + public static final String BASE_UPLOAD_FILE_URL = BASE_URL; /** * 浠h〃璇锋眰鎴愬姛 */ diff --git a/app/src/main/java/com/dayu/pipirrapp/utils/WebViewUtils.java b/app/src/main/java/com/dayu/pipirrapp/utils/WebViewUtils.java index 5a3fbb2..41c6bce 100644 --- a/app/src/main/java/com/dayu/pipirrapp/utils/WebViewUtils.java +++ b/app/src/main/java/com/dayu/pipirrapp/utils/WebViewUtils.java @@ -1,6 +1,7 @@ package com.dayu.pipirrapp.utils; import android.util.Log; +import android.view.View; import android.webkit.ConsoleMessage; import android.webkit.JsResult; import android.webkit.WebChromeClient; @@ -54,6 +55,8 @@ //鍚敤 Service Workers WebView.enableSlowWholeDocumentDraw(); WebView.setWebContentsDebuggingEnabled(true); + // 鍚敤纭欢鍔犻�� + mWebView.setLayerType(View.LAYER_TYPE_HARDWARE, null); // 缂撳瓨妯″紡 // LOAD_DEFAULT: 榛樿锛屾牴鎹� cache-control 鍐冲畾鏄惁浠庣綉缁滀笂鍙栨暟鎹� diff --git a/app/src/main/res/drawable/bottom_order_black.xml b/app/src/main/res/drawable/bottom_order_black.xml index 049673d..453cf3c 100644 --- a/app/src/main/res/drawable/bottom_order_black.xml +++ b/app/src/main/res/drawable/bottom_order_black.xml @@ -4,23 +4,44 @@ android:viewportWidth="48" android:viewportHeight="48"> <path - android:pathData="M33.05,7H38C39.105,7 40,7.895 40,9V42C40,43.105 39.105,44 38,44H10C8.895,44 8,43.105 8,42L8,9C8,7.895 8.895,7 10,7H16H17V10H31V7H33.05Z" + android:pathData="M11,8L37,8A2,2 0,0 1,39 10L39,42A2,2 0,0 1,37 44L11,44A2,2 0,0 1,9 42L9,10A2,2 0,0 1,11 8z" android:strokeLineJoin="round" android:strokeWidth="4" android:fillColor="#00000000" - android:strokeColor="#333"/> + android:strokeColor="#000000"/> <path - android:pathData="M17,4h14v6h-14z" + android:pathData="M18,4V10" android:strokeLineJoin="round" android:strokeWidth="4" android:fillColor="#00000000" - android:strokeColor="#333" + android:strokeColor="#000000" android:strokeLineCap="round"/> <path - android:pathData="M27,19L19,27.001H29.004L21,35.002" + android:pathData="M30,4V10" android:strokeLineJoin="round" android:strokeWidth="4" android:fillColor="#00000000" - android:strokeColor="#333" + android:strokeColor="#000000" + android:strokeLineCap="round"/> + <path + android:pathData="M16,19L32,19" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#000000" + android:strokeLineCap="round"/> + <path + android:pathData="M16,27L28,27" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#000000" + android:strokeLineCap="round"/> + <path + android:pathData="M16,35H24" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#000000" android:strokeLineCap="round"/> </vector> diff --git a/app/src/main/res/drawable/bottom_order_white.xml b/app/src/main/res/drawable/bottom_order_white.xml index 339b3b4..f6624a2 100644 --- a/app/src/main/res/drawable/bottom_order_white.xml +++ b/app/src/main/res/drawable/bottom_order_white.xml @@ -3,24 +3,45 @@ android:height="24dp" android:viewportWidth="48" android:viewportHeight="48"> - <path - android:pathData="M33.05,7H38C39.105,7 40,7.895 40,9V42C40,43.105 39.105,44 38,44H10C8.895,44 8,43.105 8,42L8,9C8,7.895 8.895,7 10,7H16H17V10H31V7H33.05Z" - android:strokeLineJoin="round" - android:strokeWidth="4" - android:fillColor="#00000000" - android:strokeColor="#ffffff"/> - <path - android:pathData="M17,4h14v6h-14z" - android:strokeLineJoin="round" - android:strokeWidth="4" - android:fillColor="#00000000" - android:strokeColor="#fff" - android:strokeLineCap="round"/> - <path - android:pathData="M27,19L19,27.001H29.004L21,35.002" - android:strokeLineJoin="round" - android:strokeWidth="4" - android:fillColor="#00000000" - android:strokeColor="#fff" - android:strokeLineCap="round"/> + <path + android:pathData="M11,8L37,8A2,2 0,0 1,39 10L39,42A2,2 0,0 1,37 44L11,44A2,2 0,0 1,9 42L9,10A2,2 0,0 1,11 8z" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#ffffff"/> + <path + android:pathData="M18,4V10" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#ffffff" + android:strokeLineCap="round"/> + <path + android:pathData="M30,4V10" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#ffffff" + android:strokeLineCap="round"/> + <path + android:pathData="M16,19L32,19" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#ffffff" + android:strokeLineCap="round"/> + <path + android:pathData="M16,27L28,27" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#ffffff" + android:strokeLineCap="round"/> + <path + android:pathData="M16,35H24" + android:strokeLineJoin="round" + android:strokeWidth="4" + android:fillColor="#00000000" + android:strokeColor="#ffffff" + android:strokeLineCap="round"/> </vector> diff --git a/library/.gitignore b/library/.gitignore new file mode 100644 index 0000000..107c086 --- /dev/null +++ b/library/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +.DS_Store +/build +/captures +.externalNativeBuild +.idea/ diff --git a/library/build.gradle b/library/build.gradle new file mode 100644 index 0000000..0e9fe2c --- /dev/null +++ b/library/build.gradle @@ -0,0 +1,42 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'cc.shinichi.library' + compileSdkVersion 34 + defaultConfig { + minSdkVersion 24 + targetSdkVersion 33 + multiDexEnabled true + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + compileOnly 'androidx.appcompat:appcompat:1.4.1' + compileOnly 'com.google.android.material:material:1.5.0' + compileOnly 'androidx.exifinterface:exifinterface:1.3.5' + compileOnly "androidx.core:core-ktx:1.6.0" + compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.10" + + // glide + compileOnly 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' + compileOnly 'com.github.bumptech.glide:okhttp3-integration:4.16.0' + compileOnly "com.github.zjupure:webpdecoder:2.3.4.14.2" + + // ExoPlayer https://developer.android.com/media/media3/exoplayer/hello-world?hl=zh-cn#groovy + compileOnly "androidx.media3:media3-exoplayer:1.4.1" + compileOnly "androidx.media3:media3-exoplayer-dash:1.4.1" + compileOnly "androidx.media3:media3-ui:1.4.1" +} \ No newline at end of file diff --git a/library/proguard-rules.pro b/library/proguard-rules.pro new file mode 100644 index 0000000..27b6fca --- /dev/null +++ b/library/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# Please add these rules to your existing keep rules in order to suppress warnings. +# This is generated automatically by the Android Gradle plugin. +-dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml new file mode 100644 index 0000000..869e80d --- /dev/null +++ b/library/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + + <application> + <activity + android:name=".view.ImagePreviewActivity" + android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" + android:theme="@style/Theme.ImagePreview" /> + <provider + android:name=".InitProvider" + android:authorities="${applicationId}.initprovider" + android:exported="false" /> + </application> +</manifest> \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/GlobalContext.kt b/library/src/main/java/cc/shinichi/library/GlobalContext.kt new file mode 100644 index 0000000..f0f153d --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/GlobalContext.kt @@ -0,0 +1,39 @@ +package cc.shinichi.library + +import android.app.Application +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.CacheDataSource + +/** + * 鏂囦欢鍚�: GlobalContext.java + * 浣滆��: kirito + * 鎻忚堪: 鍏ㄥ眬 + * 鍒涘缓鏃堕棿: 2024/11/27 + */ +@UnstableApi +object GlobalContext { + private var application: Application? = null + private var cacheDataSourceFactory: CacheDataSource.Factory? = null + + fun init(app: Application, cacheDataSourceFactory: CacheDataSource.Factory) { + if (application == null) { + application = app + } + if (this.cacheDataSourceFactory == null) { + this.cacheDataSourceFactory = cacheDataSourceFactory + } + } + + fun getApplication(): Application { + return application ?: throw IllegalStateException("Application is not initialized") + } + + fun getCacheDataSourceFactory(): CacheDataSource.Factory { + return cacheDataSourceFactory ?: throw IllegalStateException("CacheDataSourceFactory is not initialized") + } + + fun getContext(): Context { + return getApplication().applicationContext + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/ImagePreview.kt b/library/src/main/java/cc/shinichi/library/ImagePreview.kt new file mode 100644 index 0000000..f92841b --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/ImagePreview.kt @@ -0,0 +1,594 @@ +package cc.shinichi.library + +import android.annotation.SuppressLint +import android.app.Activity +import android.text.TextUtils +import androidx.annotation.DrawableRes +import androidx.annotation.LayoutRes +import cc.shinichi.library.bean.ImageInfo +import cc.shinichi.library.bean.Type +import cc.shinichi.library.tool.common.SLog +import cc.shinichi.library.view.ImagePreviewActivity +import cc.shinichi.library.view.listener.* +import java.lang.ref.WeakReference + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library + * create at 2018/5/22 09:06 + * description: + */ +class ImagePreview { + private var contextWeakReference: WeakReference<Activity> = WeakReference(null) + + // 鍥剧墖鏁版嵁闆嗗悎 + private var imageInfoList: MutableList<ImageInfo> = mutableListOf() + private var resImageList: MutableList<Int> = mutableListOf() + + // 榛樿鏄剧ず绗嚑涓� + var index = 0 + private set + + // 涓嬭浇鍒扮殑鏂囦欢澶瑰悕锛堟牴鐩綍涓級 + var folderName = "" + get() { + if (TextUtils.isEmpty(field)) { + field = "Download" + } + return field + } + private set + + // 鏈�灏忕缉鏀惧�嶆暟 + var minScale = 1.0f + private set + + // 涓瓑缂╂斁鍊嶆暟 + var mediumScale = 3.0f + private set + + // 鏈�澶х缉鏀惧�嶆暟 + var maxScale = 5.0f + private set + + // 鏄惁鏄剧ず鍥剧墖鎸囩ず鍣紙1/9锛� + var isShowIndicator = true + private set + + // 鏄惁鏄剧ず鍏抽棴椤甸潰鎸夐挳 + var isShowCloseButton = false + private set + + // 鏄惁鏄剧ず涓嬭浇鎸夐挳 + var isShowDownButton = true + private set + + // 鍔ㄧ敾鎸佺画鏃堕棿 鍗曚綅姣 ms + var zoomTransitionDuration = 200 + private set + + // 鏄惁鍚敤涓嬫媺鍏抽棴锛岄粯璁ゅ惎鐢� + var isEnableDragClose = true + private set + + // 鏄惁鍚敤涓婃媺鍏抽棴锛岄粯璁ゅ惎鐢� + var isEnableUpDragClose = true + private set + + // 鏄惁蹇界暐缂╂斁鍚敤鎷夊姩鍏抽棴锛岄粯璁alse锛宼rue鍗冲拷鐣� + var isEnableDragCloseIgnoreScale = true + private set + + // 鏄惁鍚敤鐐瑰嚮鍏抽棴锛岄粯璁ゅ惎鐢� + var isEnableClickClose = true + private set + + // 鏄惁鍦ㄥ姞杞藉け璐ユ椂鏄剧ずtoast + var isShowErrorToast = false + private set + + // 鍔犺浇绛栫暐 + var loadStrategy = LoadStrategy.Auto + private set + + // 闀垮浘鐨勫睍绀烘ā寮� + var longPicDisplayMode = LongPicDisplayMode.Default + private set + + @LayoutRes + var previewLayoutResId = R.layout.sh_layout_preview + private set + + var onCustomLayoutCallback: OnCustomLayoutCallback? = null + private set + + @DrawableRes + var indicatorShapeResId = R.drawable.shape_indicator_bg + private set + + @DrawableRes + var closeIconResId = R.drawable.ic_action_close + private set + + @DrawableRes + var closeIconBackgroundResId = -1 + private set + + @DrawableRes + var downIconResId = R.drawable.icon_download_new + private set + + @DrawableRes + var downIconBackgroundResId = -1 + private set + + // 鍔犺浇澶辫触鏃剁殑鍗犱綅鍥� + @DrawableRes + var errorPlaceHolder = R.drawable.load_failed + private set + + // 鐐瑰嚮鍜岄暱鎸変簨浠舵帴鍙� + var bigImageClickListener: OnBigImageClickListener? = null + private set + var bigImageLongClickListener: OnBigImageLongClickListener? = null + private set + var bigImagePageChangeListener: OnBigImagePageChangeListener? = null + private set + var downloadClickListener: OnDownloadClickListener? = null + private set + var downloadListener: OnDownloadListener? = null + private set + var onOriginProgressListener: OnOriginProgressListener? = null + private set + var onPageFinishListener: OnPageFinishListener? = null + private set + var onPageDragListener: OnPageDragListener? = null + private set + var finishListener: OnFinishListener? = null + private set + + // 鑷畾涔夌櫨鍒嗘瘮甯冨眬layout id + @LayoutRes + var progressLayoutId = -1 + private set + + // 闃叉澶氭蹇�熺偣鍑伙紝璁板綍涓婃鎵撳紑鐨勬椂闂存埑 + private var lastClickTime: Long = 0 + + fun with(context: Activity): ImagePreview { + contextWeakReference = WeakReference(context) + return this + } + + @Deprecated("璇蜂娇鐢╳ith(Context context)浠f浛") + fun setContext(context: Activity): ImagePreview { + with(context) + return this + } + + fun getImageInfoList(): MutableList<ImageInfo> { + return imageInfoList + } + + @Deprecated("璇蜂娇鐢╯etMediaInfoList(List<ImageInfo> imageInfoList)浠f浛") + fun setImageInfoList(imageInfoList: MutableList<ImageInfo>): ImagePreview { + setMediaInfoList(imageInfoList) + return this + } + + /** + * 鏀寔鍥剧墖瑙嗛娣峰悎 + */ + fun setMediaInfoList(mediaList: MutableList<ImageInfo>): ImagePreview { + this.imageInfoList.clear() + this.imageInfoList.addAll(mediaList) + return this + } + + /** + * 浠呮敮鎸佸浘鐗囩被鍨� + */ + fun setImageUrlList(imageList: MutableList<String>): ImagePreview { + var imageInfo: ImageInfo + imageInfoList.clear() + for (i in imageList.indices) { + imageInfo = ImageInfo() + imageInfo.type = Type.IMAGE + imageInfo.thumbnailUrl = imageList[i] + imageInfo.originUrl = imageList[i] + imageInfoList.add(imageInfo) + } + return this + } + + fun setImage(image: String): ImagePreview { + imageInfoList.clear() + val imageInfo = ImageInfo() + imageInfo.type = Type.IMAGE + imageInfo.thumbnailUrl = image + imageInfo.originUrl = image + imageInfoList.add(imageInfo) + return this + } + +// fun setImageRes(imageResId: Int): ImagePreview { +// resImageList.clear() +// resImageList.add(imageResId) +// return this +// } +// +// fun setImageResList(imageResIdList: MutableList<Int>): ImagePreview { +// resImageList.clear() +// resImageList.addAll(imageResIdList) +// return this +// } + + fun setIndex(index: Int): ImagePreview { + this.index = index + return this + } + + fun setShowDownButton(showDownButton: Boolean): ImagePreview { + isShowDownButton = showDownButton + return this + } + + fun setShowCloseButton(showCloseButton: Boolean): ImagePreview { + isShowCloseButton = showCloseButton + return this + } + + fun isShowOriginButton(index: Int): Boolean { + if (getImageInfoList().isEmpty()) { + return false + } + // 鏍规嵁涓嶅悓鍔犺浇绛栫暐锛岃嚜琛屽垽鏂槸鍚︽樉绀烘煡鐪嬪師鍥炬寜閽� + val originUrl = imageInfoList[index].originUrl + val thumbUrl = imageInfoList[index].thumbnailUrl + // 鍘熷浘銆佺缉鐣ュ浘url涓�鏍凤紝涓嶆樉绀烘煡鐪嬪師鍥炬寜閽� + if (originUrl.equals(thumbUrl, ignoreCase = true)) { + return false + } + return when (loadStrategy) { + LoadStrategy.Default -> { + true // 鎵嬪姩妯″紡鏃讹紝鏍规嵁鏄惁鏈夊師鍥剧紦瀛樻潵鍐冲畾鏄惁鏄剧ず鏌ョ湅鍘熷浘鎸夐挳 + } + + LoadStrategy.NetworkAuto -> { + false // 寮哄埗闅愯棌鏌ョ湅鍘熷浘鎸夐挳 + } + + LoadStrategy.AlwaysThumb -> { + false // 寮哄埗闅愯棌鏌ョ湅鍘熷浘鎸夐挳 + } + + LoadStrategy.AlwaysOrigin -> { + false // 寮哄埗闅愯棌鏌ョ湅鍘熷浘鎸夐挳 + } + + LoadStrategy.Auto -> { + true // 鏄剧ず鏌ョ湅鍘熷浘鎸夐挳 + } + } + } + + /** + * 涓嶅啀鏈夋晥锛屾槸鍚︽樉绀烘煡鐪嬪師鍥炬寜閽紝鍙栧喅浜庡姞杞界瓥鐣ワ紝LoadStrategy锛屼細鑷鍒ゆ柇鏄惁鏄剧ず銆� + */ + @Deprecated("涓嶅啀鏀寔") + fun setShowOriginButton(showOriginButton: Boolean): ImagePreview { + //isShowOriginButton = showOriginButton; + return this + } + + fun setFolderName(folderName: String): ImagePreview { + this.folderName = folderName + return this + } + + /** + * 褰撳墠鐗堟湰涓嶅啀鏀寔鏈缃紝鍙屽嚮浼氬湪鏈�灏忓拰涓瓑缂╂斁鍊间箣闂磋繘琛屽垏鎹紝鍙墜鍔ㄦ斁澶у埌鏈�澶с�� + */ + @Deprecated("涓嶅啀鏀寔") + fun setScaleMode(scaleMode: Int): ImagePreview { + //if (scaleMode != MODE_SCALE_TO_MAX_TO_MIN + // && scaleMode != MODE_SCALE_TO_MEDIUM_TO_MAX_TO_MIN + // && scaleMode != MODE_SCALE_TO_MEDIUM_TO_MIN) { + // throw new IllegalArgumentException("only can use one of( MODE_SCALE_TO_MAX_TO_MIN銆丮ODE_SCALE_TO_MEDIUM_TO_MAX_TO_MIN銆丮ODE_SCALE_TO_MEDIUM_TO_MIN )"); + //} + //this.scaleMode = scaleMode; + return this + } + + @Deprecated("涓嶅啀鏀寔锛屾瘡寮犲浘鐗囩殑缂╂斁鐢辨湰韬殑灏哄鍐冲畾") + fun setScaleLevel(min: Int, medium: Int, max: Int): ImagePreview { + if (medium in (min + 1) until max && min > 0) { + minScale = min.toFloat() + mediumScale = medium.toFloat() + maxScale = max.toFloat() + } else { + throw IllegalArgumentException("max must greater to medium, medium must greater to min!") + } + return this + } + + fun setZoomTransitionDuration(zoomTransitionDuration: Int): ImagePreview { + require(zoomTransitionDuration >= 0) { "zoomTransitionDuration must greater 0" } + this.zoomTransitionDuration = zoomTransitionDuration + return this + } + + fun setLoadStrategy(loadStrategy: LoadStrategy): ImagePreview { + this.loadStrategy = loadStrategy + return this + } + + fun setLongPicDisplayMode(longPicDisplayMode: LongPicDisplayMode): ImagePreview { + this.longPicDisplayMode = longPicDisplayMode + return this + } + + fun setEnableDragClose(enableDragClose: Boolean): ImagePreview { + isEnableDragClose = enableDragClose + return this + } + + fun setEnableUpDragClose(enableUpDragClose: Boolean): ImagePreview { + isEnableUpDragClose = enableUpDragClose + return this + } + + fun setEnableDragCloseIgnoreScale(enableDragCloseIgnoreScale: Boolean): ImagePreview { + isEnableDragCloseIgnoreScale = enableDragCloseIgnoreScale + return this + } + + fun setEnableClickClose(enableClickClose: Boolean): ImagePreview { + isEnableClickClose = enableClickClose + return this + } + + fun setShowErrorToast(showErrorToast: Boolean): ImagePreview { + isShowErrorToast = showErrorToast + return this + } + + fun setIndicatorShapeResId(indicatorShapeResId: Int): ImagePreview { + this.indicatorShapeResId = indicatorShapeResId + return this + } + + fun setCloseIconResId(@DrawableRes closeIconResId: Int): ImagePreview { + this.closeIconResId = closeIconResId + return this + } + + fun setDownIconResId(@DrawableRes downIconResId: Int): ImagePreview { + this.downIconResId = downIconResId + return this + } + + fun setCloseIconBackgroundResId(@DrawableRes closeIconBackgroundResId: Int): ImagePreview { + this.closeIconBackgroundResId = closeIconBackgroundResId + return this + } + + fun setDownIconBackgroundResId(@DrawableRes downIconBackgroundResId: Int): ImagePreview { + this.downIconBackgroundResId = downIconBackgroundResId + return this + } + + fun setShowIndicator(showIndicator: Boolean): ImagePreview { + isShowIndicator = showIndicator + return this + } + + fun setErrorPlaceHolder(errorPlaceHolderResId: Int): ImagePreview { + errorPlaceHolder = errorPlaceHolderResId + return this + } + + fun setBigImageClickListener(bigImageClickListener: OnBigImageClickListener?): ImagePreview { + this.bigImageClickListener = bigImageClickListener + return this + } + + fun setBigImageLongClickListener(bigImageLongClickListener: OnBigImageLongClickListener?): ImagePreview { + this.bigImageLongClickListener = bigImageLongClickListener + return this + } + + fun setBigImagePageChangeListener(bigImagePageChangeListener: OnBigImagePageChangeListener?): ImagePreview { + this.bigImagePageChangeListener = bigImagePageChangeListener + return this + } + + fun setDownloadClickListener(downloadClickListener: OnDownloadClickListener?): ImagePreview { + this.downloadClickListener = downloadClickListener + return this + } + + fun setDownloadListener(downloadListener: OnDownloadListener?): ImagePreview { + this.downloadListener = downloadListener + return this + } + + fun setOnPageFinishListener(onPageFinishListener: OnPageFinishListener): ImagePreview { + this.onPageFinishListener = onPageFinishListener + return this + } + + fun setOnPageDragListener(onPageDragListener: OnPageDragListener): ImagePreview { + this.onPageDragListener = onPageDragListener + return this + } + + fun setOnFinishListener(finishListener: OnFinishListener): ImagePreview { + this.finishListener = finishListener + return this + } + + private fun setOnOriginProgressListener(onOriginProgressListener: OnOriginProgressListener): ImagePreview { + this.onOriginProgressListener = onOriginProgressListener + return this + } + + fun setProgressLayoutId( + progressLayoutId: Int, + onOriginProgressListener: OnOriginProgressListener + ): ImagePreview { + setOnOriginProgressListener(onOriginProgressListener) + this.progressLayoutId = progressLayoutId + return this + } + + /** + * 瀹屽叏鑷畾涔夐瑙堢晫闈紝璇峰弬鑰冿細R.layout.sh_layout_preview + * 骞朵繚鎸佹帶浠剁被鍨嬨�乮d鍜屽叾涓竴鑷达紝鍚﹀垯浼氭壘涓嶅埌鎺т欢鑰屾姤閿� + */ + fun setPreviewLayoutResId( + previewLayoutResId: Int, + onCustomLayoutCallback: OnCustomLayoutCallback? + ): ImagePreview { + this.previewLayoutResId = previewLayoutResId + this.onCustomLayoutCallback = onCustomLayoutCallback + return this + } + + fun reset() { + imageInfoList.clear() + resImageList.clear() + + index = 0 + + folderName = "Download" + + minScale = 1.0f + mediumScale = 3.0f + maxScale = 5.0f + + isShowIndicator = true + isShowCloseButton = false + isShowDownButton = true + + zoomTransitionDuration = 200 + + isEnableDragClose = true + isEnableUpDragClose = true + isEnableDragCloseIgnoreScale = true + isEnableClickClose = true + + isShowErrorToast = false + + loadStrategy = LoadStrategy.Default + longPicDisplayMode = LongPicDisplayMode.Default + + previewLayoutResId = R.layout.sh_layout_preview + + onCustomLayoutCallback = null + + indicatorShapeResId = R.drawable.shape_indicator_bg + closeIconResId = R.drawable.ic_action_close + downIconResId = R.drawable.icon_download_new + closeIconBackgroundResId = -1 + downIconBackgroundResId = -1 + errorPlaceHolder = R.drawable.load_failed + + bigImageClickListener = null + bigImageLongClickListener = null + bigImagePageChangeListener = null + downloadClickListener = null + downloadListener = null + onOriginProgressListener = null + onPageFinishListener = null + onPageDragListener = null + finishListener = null + + progressLayoutId = -1 + lastClickTime = 0 + + contextWeakReference.clear() + } + + fun start() { + if (System.currentTimeMillis() - lastClickTime <= MIN_DOUBLE_CLICK_TIME) { + SLog.e("ImagePreview", "---蹇界暐澶氭蹇�熺偣鍑�---") + return + } + val context = contextWeakReference.get() ?: throw IllegalArgumentException("You must call 'setContext(Context context)' first!") + if (context.isFinishing || context.isDestroyed) { + reset() + return + } + require(imageInfoList.isNotEmpty() || resImageList.isNotEmpty()) { "娌℃湁鏁版嵁婧�!" } + require(index < imageInfoList.size || index < resImageList.size) { "index out of bound!" } + lastClickTime = System.currentTimeMillis() + ImagePreviewActivity.activityStart(context) + } + + /** + * 鎵嬪姩鍏抽棴椤甸潰 + */ + fun finish() { + finishListener?.onFinish() + } + + enum class LoadStrategy { + /** + * 浠呭姞杞藉師鍥撅紱浼氬己鍒堕殣钘忔煡鐪嬪師鍥炬寜閽� + */ + AlwaysOrigin, + + /** + * 浠呭姞杞芥櫘娓咃紱浼氬己鍒堕殣钘忔煡鐪嬪師鍥炬寜閽� + */ + AlwaysThumb, + + /** + * 鏍规嵁缃戠粶鑷�傚簲鍔犺浇锛學iFi鍘熷浘锛屾祦閲忔櫘娓咃紱浼氬己鍒堕殣钘忔煡鐪嬪師鍥炬寜閽� + */ + NetworkAuto, + + /** + * 鎵嬪姩妯″紡锛氶粯璁ゆ櫘娓咃紝鐐瑰嚮鎸夐挳鍐嶅姞杞藉師鍥撅紱浼氭牴鎹師鍥俱�佺缉鐣ュ浘url鏄惁涓�鏍锋潵鍒ゆ柇鏄惁鏄剧ず鏌ョ湅鍘熷浘鎸夐挳 + */ + Default, + + /** + * 鍏ㄨ嚜鍔ㄦā寮忥細WiFi鍘熷浘锛屾祦閲忎笅榛樿鏅竻锛屽彲鐐瑰嚮鎸夐挳鏌ョ湅鍘熷浘 + */ + Auto + } + + enum class LongPicDisplayMode { + /** + * 缂╁皬濉厖锛屽弻鍑绘媺婊★紝鍙墜鍔ㄧ缉鏀� + */ + Default, + + /** + * 宸﹀彸鎷夋弧锛屽弻鍑荤缉灏忥紝鍙墜鍔ㄧ缉鏀� + * 涓�鑸珫灞忔墜鏈轰娇鐢� + */ + FillWidth, + } + + private object InnerClass { + @SuppressLint("StaticFieldLeak") + val instance = ImagePreview() + } + + companion object { + @JvmField + @LayoutRes + val PROGRESS_THEME_CIRCLE_TEXT = R.layout.sh_default_progress_layout + + // 瑙﹀彂鍙屽嚮鐨勬渶鐭椂闂达紝灏忎簬杩欎釜鏃堕棿鐨勭洿鎺ヨ繑鍥� + private const val MIN_DOUBLE_CLICK_TIME = 1500 + + @JvmStatic + val instance: ImagePreview + get() = InnerClass.instance + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/InitProvider.kt b/library/src/main/java/cc/shinichi/library/InitProvider.kt new file mode 100644 index 0000000..f702222 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/InitProvider.kt @@ -0,0 +1,75 @@ +package cc.shinichi.library + +import android.app.Application +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import java.io.File + +/** + * 鏂囦欢鍚�: InitProvider.java + * 浣滆��: kirito + * 鎻忚堪: 鍒濆鍖� + * 鍒涘缓鏃堕棿: 2024/11/27 + */ +@UnstableApi +class InitProvider : ContentProvider() { + + override fun onCreate(): Boolean { + // 鑾峰彇 Application 瀹炰緥 + val application = context?.applicationContext as? Application + if (application != null) { + // 鍦ㄨ繖閲岃繘琛屽垵濮嬪寲鎿嶄綔 + initializeLibrary(application) + } + return true // 杩斿洖 true 琛ㄧず鎴愬姛鍒濆鍖� + } + + private fun initializeLibrary(application: Application) { + // downloadDirectory + val downloadDirectory = File(application.cacheDir, "media_cache") + // maxBytes 500MB + val maxBytes = 500 * 1024 * 1024L + // Note: This should be a singleton in your app. + val databaseProvider = StandaloneDatabaseProvider(application) + // An on-the-fly cache should evict media when reaching a maximum disk space limit. + val cache = SimpleCache( + downloadDirectory, + LeastRecentlyUsedCacheEvictor(maxBytes), + databaseProvider + ) + val dataSourceFactory = DefaultDataSource.Factory( + application, + DefaultHttpDataSource.Factory() + ) + // Configure the DataSource.Factory with the cache and factory for the desired HTTP stack. + var cacheDataSourceFactory = + CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(dataSourceFactory) + GlobalContext.init(application, cacheDataSourceFactory) + } + + override fun query( + uri: Uri, projection: Array<out String>?, selection: String?, + selectionArgs: Array<out String>?, sortOrder: String? + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0 + + override fun update( + uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>? + ): Int = 0 + + override fun getType(uri: Uri): String? = null +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/bean/ImageInfo.kt b/library/src/main/java/cc/shinichi/library/bean/ImageInfo.kt new file mode 100644 index 0000000..e60806d --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/bean/ImageInfo.kt @@ -0,0 +1,27 @@ +package cc.shinichi.library.bean + +import java.io.Serializable + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * 鍥剧墖淇℃伅 + */ +class ImageInfo : Serializable { + /** + * 绫诲瀷 + */ + var type: Type = Type.IMAGE // image / video + /** + * 缂╃暐鍥� + */ + var thumbnailUrl: String = "" + /** + * 鍘熷浘 + */ + var originUrl: String = "" +} + +enum class Type { + IMAGE, VIDEO +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/FileTarget.kt b/library/src/main/java/cc/shinichi/library/glide/FileTarget.kt new file mode 100644 index 0000000..c39a983 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/FileTarget.kt @@ -0,0 +1,44 @@ +package cc.shinichi.library.glide + +import android.graphics.drawable.Drawable +import com.bumptech.glide.request.Request +import com.bumptech.glide.request.target.SizeReadyCallback +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import java.io.File + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.glide + * create at 2018/11/2 17:12 + * description: + */ +open class FileTarget : Target<File> { + + override fun onLoadStarted(placeholder: Drawable?) {} + + override fun onLoadFailed(errorDrawable: Drawable?) {} + + override fun onResourceReady(resource: File, transition: Transition<in File>?) {} + + override fun onLoadCleared(placeholder: Drawable?) {} + + override fun getSize(cb: SizeReadyCallback) { + cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + } + + override fun removeCallback(cb: SizeReadyCallback) {} + + override fun getRequest(): Request? { + return null + } + + override fun setRequest(request: Request?) {} + + override fun onStart() {} + + override fun onStop() {} + + override fun onDestroy() {} +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/ImageLoader.kt b/library/src/main/java/cc/shinichi/library/glide/ImageLoader.kt new file mode 100644 index 0000000..26e2e9f --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/ImageLoader.kt @@ -0,0 +1,55 @@ +package cc.shinichi.library.glide + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import cc.shinichi.library.glide.cache.DataCacheKey +import cc.shinichi.library.glide.cache.SafeKeyGenerator +import cc.shinichi.library.tool.common.SLog +import com.bumptech.glide.Glide +import com.bumptech.glide.disklrucache.DiskLruCache +import com.bumptech.glide.load.engine.cache.DiskCache +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.signature.EmptySignature +import java.io.File + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.glide + * create at 2018/5/21 15:22 + * description: + */ +object ImageLoader { + + private const val TAG = "ImageLoader" + + /** + * 鑾峰彇鏄惁鏈夋煇寮犲師鍥剧殑缂撳瓨 + * 缂撳瓨妯″紡蹇呴』鏄細DiskCacheStrategy.SOURCE 鎵嶈兘鑾峰彇鍒扮紦瀛樻枃浠� + */ + fun getGlideCacheFile(context: Context, url: String?): File? { + try { + val dataCacheKey = DataCacheKey(GlideUrl(url), EmptySignature.obtain()) + val safeKeyGenerator = SafeKeyGenerator() + val safeKey = safeKeyGenerator.getSafeKey(dataCacheKey) + SLog.d(TAG, "safeKey = $safeKey") + val file = File(context.cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR) + val diskLruCache = DiskLruCache.open(file, 1, 1, DiskCache.Factory.DEFAULT_DISK_CACHE_SIZE.toLong()) + val value = diskLruCache[safeKey] + return value?.getFile(0) + } catch (e: Exception) { + e.printStackTrace() + } + return null + } + + @JvmStatic + fun clearMemory(activity: AppCompatActivity) { + Glide.get(activity.applicationContext).clearMemory() + } + + @JvmStatic + fun cleanDiskCache(context: Context) { + Thread { Glide.get(context.applicationContext).clearDiskCache() }.start() + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/SSLSocketClient.kt b/library/src/main/java/cc/shinichi/library/glide/SSLSocketClient.kt new file mode 100644 index 0000000..7c7b741 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/SSLSocketClient.kt @@ -0,0 +1,63 @@ +package cc.shinichi.library.glide + +import android.annotation.SuppressLint +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.* + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.glide.progress + * create at 2018/11/2 15:55 + * description: + */ +object SSLSocketClient { + + val sSLSocketFactory: SSLSocketFactory + get() = try { + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustManager, SecureRandom()) + sslContext.socketFactory + } catch (e: Exception) { + throw RuntimeException(e) + } + + private val trustManager: Array<TrustManager> + get() = arrayOf( + @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) { + } + + override fun getAcceptedIssuers(): Array<X509Certificate> { + return arrayOf() + } + } + ) + + @SuppressLint("CustomX509TrustManager") + fun geX509tTrustManager(): X509TrustManager { + return object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) { + } + + override fun getAcceptedIssuers(): Array<X509Certificate> { + return arrayOf() + } + } + } + + val hostnameVerifier: HostnameVerifier + get() = HostnameVerifier { _, _ -> true } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/cache/DataCacheKey.kt b/library/src/main/java/cc/shinichi/library/glide/cache/DataCacheKey.kt new file mode 100644 index 0000000..7d12776 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/cache/DataCacheKey.kt @@ -0,0 +1,35 @@ +package cc.shinichi.library.glide.cache + +import com.bumptech.glide.load.Key +import java.security.MessageDigest + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * create at 2018/5/10 11:12 + * description: + */ +class DataCacheKey(val sourceKey: Key, private val signature: Key) : Key { + + override fun equals(o: Any?): Boolean { + if (o is DataCacheKey) { + return sourceKey == o.sourceKey && signature == o.signature + } + return false + } + + override fun hashCode(): Int { + var result = sourceKey.hashCode() + result = 31 * result + signature.hashCode() + return result + } + + override fun toString(): String { + return "DataCacheKey{sourceKey=$sourceKey, signature=$signature}" + } + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + sourceKey.updateDiskCacheKey(messageDigest) + signature.updateDiskCacheKey(messageDigest) + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/cache/SafeKeyGenerator.kt b/library/src/main/java/cc/shinichi/library/glide/cache/SafeKeyGenerator.kt new file mode 100644 index 0000000..5c67f37 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/cache/SafeKeyGenerator.kt @@ -0,0 +1,36 @@ +package cc.shinichi.library.glide.cache + +import com.bumptech.glide.load.Key +import com.bumptech.glide.util.LruCache +import com.bumptech.glide.util.Util +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * create at 2018/5/10 11:11 + * description: + */ +class SafeKeyGenerator { + + private val loadIdToSafeHash = LruCache<Key, String>(1000) + + fun getSafeKey(key: Key): String? { + var safeKey: String? + synchronized(loadIdToSafeHash) { safeKey = loadIdToSafeHash[key] } + if (safeKey == null) { + try { + val messageDigest = MessageDigest.getInstance("SHA-256") + key.updateDiskCacheKey(messageDigest) + safeKey = Util.sha256BytesToHex(messageDigest.digest()) + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } catch (e: Exception) { + e.printStackTrace() + } + synchronized(loadIdToSafeHash) { loadIdToSafeHash.put(key, safeKey) } + } + return safeKey + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/progress/OnProgressListener.kt b/library/src/main/java/cc/shinichi/library/glide/progress/OnProgressListener.kt new file mode 100644 index 0000000..2b44004 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/progress/OnProgressListener.kt @@ -0,0 +1,15 @@ +package cc.shinichi.library.glide.progress + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +interface OnProgressListener { + fun onProgress( + url: String?, + isComplete: Boolean, + percentage: Int, + bytesRead: Long, + totalBytes: Long + ) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/progress/ProgressLibraryGlideModule.kt b/library/src/main/java/cc/shinichi/library/glide/progress/ProgressLibraryGlideModule.kt new file mode 100644 index 0000000..a720d4c --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/progress/ProgressLibraryGlideModule.kt @@ -0,0 +1,27 @@ +package cc.shinichi.library.glide.progress + +import android.content.Context +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.module.LibraryGlideModule +import java.io.InputStream + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +@GlideModule +class ProgressLibraryGlideModule : LibraryGlideModule() { + + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + super.registerComponents(context, glide, registry) + registry.replace( + GlideUrl::class.java, + InputStream::class.java, + OkHttpUrlLoader.Factory(ProgressManager.okHttpClient) + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/progress/ProgressManager.kt b/library/src/main/java/cc/shinichi/library/glide/progress/ProgressManager.kt new file mode 100644 index 0000000..efc7bfd --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/progress/ProgressManager.kt @@ -0,0 +1,70 @@ +package cc.shinichi.library.glide.progress + +import android.text.TextUtils +import cc.shinichi.library.glide.SSLSocketClient +import cc.shinichi.library.glide.progress.ProgressResponseBody.InternalProgressListener +import okhttp3.OkHttpClient +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +object ProgressManager { + + private val listenersMap = Collections.synchronizedMap(HashMap<String, OnProgressListener>()) + + private val LISTENER = object : InternalProgressListener { + override fun onProgress(url: String?, bytesRead: Long, totalBytes: Long) { + val percentage = (bytesRead * 1f / totalBytes * 100f).toInt() + val isComplete = percentage >= 100 + listenersMap.let { + for (listener in it.values) { + listener.onProgress(url, isComplete, percentage, bytesRead, totalBytes) + } + } + if (isComplete) { + removeListener(url) + } + } + } + + @JvmStatic + val okHttpClient: OkHttpClient + get() { + val builder = OkHttpClient.Builder() + builder.addNetworkInterceptor { chain -> + val request = chain.request() + val response = chain.proceed(request) + response.newBuilder() + .body( + response.body() + ?.let { ProgressResponseBody(request.url().toString(), LISTENER, it) }) + .build() + } + .sslSocketFactory( + SSLSocketClient.sSLSocketFactory, + SSLSocketClient.geX509tTrustManager() + ) + .hostnameVerifier(SSLSocketClient.hostnameVerifier) + builder.connectTimeout(30, TimeUnit.SECONDS) + builder.writeTimeout(30, TimeUnit.SECONDS) + builder.readTimeout(30, TimeUnit.SECONDS) + return builder.build() + } + + @JvmStatic + fun addListener(url: String?, listener: OnProgressListener?) { + if (!TextUtils.isEmpty(url)) { + listenersMap[url] = listener + listener?.onProgress(url, false, 1, 0, 0) + } + } + + private fun removeListener(url: String?) { + if (!TextUtils.isEmpty(url)) { + listenersMap.remove(url) + } + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/glide/progress/ProgressResponseBody.kt b/library/src/main/java/cc/shinichi/library/glide/progress/ProgressResponseBody.kt new file mode 100644 index 0000000..6813082 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/glide/progress/ProgressResponseBody.kt @@ -0,0 +1,62 @@ +package cc.shinichi.library.glide.progress + +import android.os.Handler +import android.os.Looper +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.* +import java.io.IOException + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +class ProgressResponseBody internal constructor( + private val url: String, + private val internalProgressListener: InternalProgressListener?, + private val responseBody: ResponseBody +) : ResponseBody() { + + private lateinit var bufferedSource: BufferedSource + + override fun contentType(): MediaType? { + return responseBody.contentType() + } + + override fun contentLength(): Long { + return responseBody.contentLength() + } + + override fun source(): BufferedSource { + bufferedSource = Okio.buffer(source(responseBody.source())) + return bufferedSource + } + + private fun source(source: Source): Source { + return object : ForwardingSource(source) { + var totalBytesRead: Long = 0 + var lastTotalBytesRead: Long = 0 + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + totalBytesRead += if (bytesRead == -1L) 0 else bytesRead + if (lastTotalBytesRead != totalBytesRead) { + lastTotalBytesRead = totalBytesRead + mainThreadHandler.post { + internalProgressListener?.onProgress(url, totalBytesRead, contentLength()) + } + } + return bytesRead + } + } + } + + internal interface InternalProgressListener { + fun onProgress(url: String?, bytesRead: Long, totalBytes: Long) + } + + companion object { + private val mainThreadHandler = Handler(Looper.getMainLooper()) + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/DeviceUtil.kt b/library/src/main/java/cc/shinichi/library/tool/common/DeviceUtil.kt new file mode 100644 index 0000000..3bad58a --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/DeviceUtil.kt @@ -0,0 +1,54 @@ +package cc.shinichi.library.tool.common + +import android.text.TextUtils + +object DeviceUtil { + + /** + * 鏄惁涓洪缚钂欑郴缁� + * + * @return true涓洪缚钂欑郴缁� + */ + fun isHarmonyOs(): Boolean { + return try { + val buildExClass = Class.forName("com.huawei.system.BuildEx") + val osBrand = buildExClass.getMethod("getOsBrand").invoke(buildExClass) + osBrand.toString().contains("harmony", ignoreCase = true) + } catch (x: Throwable) { + false + } + } + + /** + * 鑾峰彇楦胯挋绯荤粺鐗堟湰鍙� + * + * @return 鐗堟湰鍙� + */ + fun getHarmonyVersion(): String? { + return getProp("hw_sc.build.os.version", "") + } + + /** + * 鑾峰彇楦胯挋绯荤粺鐗堟湰鍙� + * 楦胯挋2.0鐗堟湰鍙蜂负6 + * 楦胯挋3.0鐗堟湰鍙蜂负8 + * @return 鐗堟湰鍙� + */ + fun getHarmonyVersionCode(): Int { + return getProp("hw_sc.build.os.apiversion", "0")?.toInt() ?: 0 + } + + private fun getProp(property: String, defaultValue: String): String? { + try { + val spClz = Class.forName("android.os.SystemProperties") + val method = spClz.getDeclaredMethod("get", String::class.java) + val value = method.invoke(spClz, property) as String + return if (TextUtils.isEmpty(value)) { + defaultValue + } else value + } catch (e: Throwable) { + e.printStackTrace() + } + return defaultValue + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/HandlerHolder.kt b/library/src/main/java/cc/shinichi/library/tool/common/HandlerHolder.kt new file mode 100644 index 0000000..294e93d --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/HandlerHolder.kt @@ -0,0 +1,25 @@ +package cc.shinichi.library.tool.common + +import android.os.Handler +import android.os.Looper +import android.os.Message +import java.lang.ref.WeakReference + +/** + * Handler鐩稿叧宸ュ叿绫� + * + * 瀹炵幇 os.handler鐨刢allback鎺ュ彛 + * + * 鍦ㄩ渶瑕佸鐩存帴璋冪敤handler.sendmessage...鍗冲彲 + * implements Callback + * private HandlerUtils.HandlerHolder handlerHolder; + * handlerHolder = new HandlerHolder(this); + */ +class HandlerHolder(listener: Callback?) : Handler(Looper.getMainLooper()) { + + private var mListenerWeakReference: WeakReference<Callback?>? = WeakReference(listener) + + override fun handleMessage(msg: Message) { + mListenerWeakReference?.get()?.handleMessage(msg) + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/HttpUtil.kt b/library/src/main/java/cc/shinichi/library/tool/common/HttpUtil.kt new file mode 100644 index 0000000..493d92f --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/HttpUtil.kt @@ -0,0 +1,66 @@ +package cc.shinichi.library.tool.common + +import java.io.* +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL +import java.net.URLDecoder + +/** + * HttpURLConnection 涓嬭浇鍥剧墖 + */ +object HttpUtil { + + /** + * @param urlPath 涓嬭浇璺緞 + * @param downloadDir 涓嬭浇瀛樻斁鐩綍 + * @return 杩斿洖涓嬭浇鏂囦欢 + */ + fun downloadFile(urlPath: String?, fileFullName: String, downloadDir: String): File? { + var file: File? = null + try { + // 缁熶竴璧勬簮 + val url = URL(urlPath) + // 杩炴帴绫荤殑鐖剁被锛屾娊璞$被 + val urlConnection = url.openConnection() + // http鐨勮繛鎺ョ被 + val httpURLConnection = urlConnection as HttpURLConnection + // 璁惧畾璇锋眰鐨勬柟娉曪紝榛樿鏄疓ET + httpURLConnection.requestMethod = "GET" + // 璁剧疆瀛楃缂栫爜 + httpURLConnection.setRequestProperty("Charset", "UTF-8") + // 鎵撳紑鍒版 URL 寮曠敤鐨勮祫婧愮殑閫氫俊閾炬帴锛堝鏋滃皻鏈缓绔嬭繖鏍风殑杩炴帴锛夈�� + httpURLConnection.connect() + val bin = BufferedInputStream(httpURLConnection.inputStream) + val path = downloadDir + File.separatorChar + fileFullName + file = File(path) + file.parentFile?.let { + if (!it.exists()) { + it.mkdirs() + } + } + val out: OutputStream = FileOutputStream(file) + var size = 0 + var len = 0 + val buf = ByteArray(1024) + while (bin.read(buf).also { size = it } != -1) { + len += size + out.write(buf, 0, size) + } + bin.close() + out.close() + return file + } catch (e: MalformedURLException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } catch (e: Exception) { + e.printStackTrace() + } + return null + } + + fun decode(text: String): String { + return URLDecoder.decode(text) + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/NetworkUtil.kt b/library/src/main/java/cc/shinichi/library/tool/common/NetworkUtil.kt new file mode 100644 index 0000000..8daa585 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/NetworkUtil.kt @@ -0,0 +1,25 @@ +package cc.shinichi.library.tool.common + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkInfo + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +object NetworkUtil { + + private val TAG = "NetworkUtil" + + fun isWiFi(context: Context): Boolean { + val info = getActiveNetworkInfo(context) + val isWifi = info != null && info.isAvailable && info.type == ConnectivityManager.TYPE_WIFI + SLog.d(TAG, "isWiFi: $isWifi") + return isWifi + } + + private fun getActiveNetworkInfo(context: Context): NetworkInfo? { + return (context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/PhoneUtil.kt b/library/src/main/java/cc/shinichi/library/tool/common/PhoneUtil.kt new file mode 100644 index 0000000..5e0ddbb --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/PhoneUtil.kt @@ -0,0 +1,66 @@ +package cc.shinichi.library.tool.common + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.util.DisplayMetrics +import android.view.Display +import android.view.WindowManager + + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.drawlongpicturedemo.util + * create at 2018/8/27 17:53 + * description: + */ +object PhoneUtil { + + private val TAG = PhoneUtil::class.java.simpleName + + fun getPhoneWid(context: Context): Int { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display: Display = wm.defaultDisplay + var screenWidth = 0 + val dm = DisplayMetrics() + display.getRealMetrics(dm) + screenWidth = dm.widthPixels + return screenWidth.apply { + SLog.d(TAG, "getPhoneWid: $this") + } + } + + fun getPhoneHei(context: Context): Int { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display = wm.defaultDisplay + var screenHeight = 0 + + val dm = DisplayMetrics() + display.getRealMetrics(dm) + screenHeight = dm.heightPixels + return screenHeight.apply { + SLog.d(TAG, "getPhoneHei: $this") + } + } + + fun getPhoneRatio(context: Context): Float { + return (getPhoneHei(context).toFloat() / getPhoneWid(context).toFloat()).apply { + SLog.d(TAG, "getPhoneRatio: $this") + } + } + + @SuppressLint("InternalInsetResource") + fun getNavBarHeight(context: Context): Int { + val resources: Resources = context.resources + val resourceId: Int = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) + } + + @SuppressLint("InternalInsetResource") + fun getStatusBarHeight(context: Context): Int { + val resources: Resources = context.resources + val resourceId: Int = resources.getIdentifier("status_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/SLog.kt b/library/src/main/java/cc/shinichi/library/tool/common/SLog.kt new file mode 100644 index 0000000..d02dd15 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/SLog.kt @@ -0,0 +1,99 @@ +package cc.shinichi.library.tool.common + +import android.util.Log + +/** + * 鏂囦欢鍚�: SLog.java + * 浣滆��: kirito + * 鎻忚堪: 鍐呴儴鏃ュ織鎵撳嵃锛屽彲浠ユ牴鎹槸鍚ebug鏉ュ喅瀹氭槸鍚︽墦鍗� + * 鍒涘缓鏃堕棿: 2024/11/22 + */ +object SLog { + + private const val TAG = "SLog" + private val isDebug = true + + fun d(msg: String) { + if (isDebug) { + Log.d(TAG, msg) + } + } + + fun e(msg: String) { + if (isDebug) { + Log.e(TAG, msg) + } + } + + fun i(msg: String) { + if (isDebug) { + Log.i(TAG, msg) + } + } + + fun w(msg: String) { + if (isDebug) { + Log.w(TAG, msg) + } + } + + fun v(msg: String) { + if (isDebug) { + Log.v(TAG, msg) + } + } + + fun d(tag: String, msg: String) { + if (isDebug) { + Log.d(tag, msg) + } + } + + fun e(tag: String, msg: String) { + if (isDebug) { + Log.e(tag, msg) + } + } + + fun i(tag: String, msg: String) { + if (isDebug) { + Log.i(tag, msg) + } + } + + fun w(tag: String, msg: String) { + if (isDebug) { + Log.w(tag, msg) + } + } + + fun v(tag: String, msg: String) { + if (isDebug) { + Log.v(tag, msg) + } + } + + fun d(tag: String, msg: String, tr: Throwable) { + if (isDebug) { + Log.d(tag, msg, tr) + } + } + + fun e(tag: String, msg: String, tr: Throwable) { + if (isDebug) { + Log.e(tag, msg, tr) + } + } + + fun i(tag: String, msg: String, tr: Throwable) { + if (isDebug) { + Log.i(tag, msg, tr) + } + } + + fun w(tag: String, msg: String, tr: Throwable?) { + if (isDebug) { + Log.w(tag, msg, tr) + } + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/ToastUtil.kt b/library/src/main/java/cc/shinichi/library/tool/common/ToastUtil.kt new file mode 100644 index 0000000..7492d7c --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/ToastUtil.kt @@ -0,0 +1,33 @@ +package cc.shinichi.library.tool.common + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +class ToastUtil { + + fun showShort(context: Context, text: String?) { + HANDLER.post { Toast.makeText(context.applicationContext, text, Toast.LENGTH_SHORT).show() } + } + + fun showLong(context: Context, text: String?) { + HANDLER.post { Toast.makeText(context.applicationContext, text, Toast.LENGTH_LONG).show() } + } + + private object InnerClass { + val instance = ToastUtil() + } + + companion object { + private val HANDLER = Handler(Looper.getMainLooper()) + + @JvmStatic + val instance: ToastUtil + get() = InnerClass.instance + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/common/UIUtil.kt b/library/src/main/java/cc/shinichi/library/tool/common/UIUtil.kt new file mode 100644 index 0000000..85ac5f7 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/common/UIUtil.kt @@ -0,0 +1,32 @@ +package cc.shinichi.library.tool.common + +import android.content.Context + +/** + * 鏂囦欢鍚�: UIUtil.java + * 浣滆��: kirito + * 鎻忚堪: UI宸ュ叿 + * 鍒涘缓鏃堕棿: 2024/11/25 + */ +object UIUtil { + + fun dp2px(context: Context, dp: Float): Int { + val scale = context.resources.displayMetrics.density + return (dp * scale + 0.5f).toInt() + } + + fun px2dp(context: Context, px: Int): Float { + val scale = context.resources.displayMetrics.density + return px / scale + 0.5f + } + + fun sp2px(context: Context, sp: Float): Int { + val scale = context.resources.displayMetrics.scaledDensity + return (sp * scale + 0.5f).toInt() + } + + fun px2sp(context: Context, px: Int): Float { + val scale = context.resources.displayMetrics.scaledDensity + return px / scale + 0.5f + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/file/FileUtil.kt b/library/src/main/java/cc/shinichi/library/tool/file/FileUtil.kt new file mode 100644 index 0000000..f00e2b1 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/file/FileUtil.kt @@ -0,0 +1,162 @@ +package cc.shinichi.library.tool.file + +import android.content.Context +import android.os.Environment +import android.text.TextUtils +import java.io.* +import java.nio.channels.FileChannel + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +class FileUtil { + + companion object { + /** + * 鑾峰彇鍙敤鐨刢ache璺緞 + */ + fun getAvailableCacheDir(context: Context): File? { + val file: File? = if (isExternalStorageWritable) { + context.externalCacheDir + } else { + context.cacheDir + } + return file + } + + private val isExternalStorageWritable: Boolean + get() { + val state = Environment.getExternalStorageState() + return Environment.MEDIA_MOUNTED == state + } + + /** + * Return the file by path. + * + * @param filePath The path of file. + * @return the file + */ + private fun getFileByPath(filePath: String?): File? { + return if (isSpace(filePath)) null else filePath?.let { File(it) } + } + + /** + * Create a directory if it doesn't exist, otherwise do nothing. + * + * @param file The file. + * @return `true`: exists or creates successfully<br></br>`false`: otherwise + */ + private fun createOrExistsDir(file: File?): Boolean { + return file != null && if (file.exists()) file.isDirectory else file.mkdirs() + } + + /** + * Create a file if it doesn't exist, otherwise delete old file before creating. + * + * @param filePath The path of file. + * @return `true`: success<br></br>`false`: fail + */ + fun createFileByDeleteOldFile(filePath: String?): Boolean { + return createFileByDeleteOldFile(getFileByPath(filePath)) + } + + /** + * Create a file if it doesn't exist, otherwise delete old file before creating. + * + * @param file The file. + * @return `true`: success<br></br>`false`: fail + */ + private fun createFileByDeleteOldFile(file: File?): Boolean { + if (file == null) { + return false + } + // file exists and unsuccessfully delete then return false + if (file.exists() && !file.delete()) { + return false + } + return if (!createOrExistsDir(file.parentFile)) { + false + } else try { + file.createNewFile() + } catch (e: IOException) { + e.printStackTrace() + false + } + } + + private fun isSpace(s: String?): Boolean { + if (s == null) { + return true + } + var i = 0 + val len = s.length + while (i < len) { + if (!Character.isWhitespace(s[i])) { + return false + } + ++i + } + return true + } + + /** + * 鏍规嵁鏂囦欢璺緞鎷疯礉鏂囦欢 + * + * @param resourceFile 婧愭枃浠� + * @param targetPath 鐩爣璺緞锛堝寘鍚枃浠跺悕鍜屾枃浠舵牸寮忥級 + * @return boolean 鎴愬姛true銆佸け璐alse + */ + fun copyFile(resourceFile: File?, targetPath: String, fileName: String): Boolean { + var result = false + if (resourceFile == null || TextUtils.isEmpty(targetPath)) { + return result + } + val target = File(targetPath) + if (target.exists()) { + target.delete() // 宸插瓨鍦ㄧ殑璇濆厛鍒犻櫎 + } else { + try { + target.mkdirs() + } catch (e: Exception) { + e.printStackTrace() + } + } + val targetFile = File(targetPath + fileName) + if (targetFile.exists()) { + targetFile.delete() + } else { + try { + targetFile.createNewFile() + } catch (e: IOException) { + e.printStackTrace() + } + } + var resourceChannel: FileChannel? = null + var targetChannel: FileChannel? = null + try { + resourceChannel = FileInputStream(resourceFile).channel + targetChannel = FileOutputStream(targetFile).channel + resourceChannel.transferTo(0, resourceChannel.size(), targetChannel) + result = true + } catch (e: FileNotFoundException) { + e.printStackTrace() + return result + } catch (e: IOException) { + e.printStackTrace() + return result + } + try { + resourceChannel.close() + targetChannel.close() + } catch (e: IOException) { + e.printStackTrace() + } + return result + } + } + + init { + throw UnsupportedOperationException("u can't instantiate me...") + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/file/SingleMediaScanner.kt b/library/src/main/java/cc/shinichi/library/tool/file/SingleMediaScanner.kt new file mode 100644 index 0000000..015ddfa --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/file/SingleMediaScanner.kt @@ -0,0 +1,39 @@ +package cc.shinichi.library.tool.file + +import android.content.Context +import android.media.MediaScannerConnection +import android.media.MediaScannerConnection.MediaScannerConnectionClient +import android.net.Uri + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * create at 2018/5/4 16:50 + * description:濯掍綋鎵弿 + */ +class SingleMediaScanner( + context: Context?, + private val path: String, + private val listener: ScanListener? +) : + MediaScannerConnectionClient { + + private val mMs: MediaScannerConnection = MediaScannerConnection(context, this) + + override fun onMediaScannerConnected() { + mMs.scanFile(path, null) + } + + override fun onScanCompleted(path: String, uri: Uri) { + mMs.disconnect() + listener?.onScanFinish() + } + + interface ScanListener { + fun onScanFinish() + } + + init { + mMs.connect() + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/image/DownloadUtil.kt b/library/src/main/java/cc/shinichi/library/tool/image/DownloadUtil.kt new file mode 100644 index 0000000..f43b8f5 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/image/DownloadUtil.kt @@ -0,0 +1,287 @@ +package cc.shinichi.library.tool.image + +import android.app.Activity +import android.content.ContentValues +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import cc.shinichi.library.ImagePreview +import cc.shinichi.library.R +import cc.shinichi.library.glide.FileTarget +import cc.shinichi.library.tool.common.HttpUtil.downloadFile +import cc.shinichi.library.tool.common.ToastUtil +import cc.shinichi.library.tool.file.FileUtil.Companion.copyFile +import cc.shinichi.library.tool.file.FileUtil.Companion.createFileByDeleteOldFile +import cc.shinichi.library.tool.file.FileUtil.Companion.getAvailableCacheDir +import cc.shinichi.library.tool.file.SingleMediaScanner +import cc.shinichi.library.tool.image.ImageUtil.refresh +import com.bumptech.glide.Glide +import com.bumptech.glide.request.transition.Transition +import java.io.* + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * create at 2018/5/4 16:34 + * description:鍥剧墖涓嬭浇宸ュ叿绫� + */ +object DownloadUtil { + + fun downloadPicture(context: Activity, currentItem: Int, url: String?) { + Glide.with(context).downloadOnly().load(url).into(object : FileTarget() { + override fun onLoadStarted(placeholder: Drawable?) { + super.onLoadStarted(placeholder) + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadStart(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_start_download) + ) + } + super.onLoadStarted(placeholder) + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + super.onLoadFailed(errorDrawable) + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadFailed(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_failed) + ) + } + } + + override fun onResourceReady(resource: File, transition: Transition<in File>?) { + super.onResourceReady(resource, transition) + saveImage(context, resource, currentItem) + } + }) + } + + private fun saveImage(context: Activity, resource: File, currentItem: Int) { + // 浼犲叆鐨勪繚瀛樻枃浠跺す鍚� + val downloadFolderName = ImagePreview.instance.folderName + // 淇濆瓨鐨勫浘鐗囧悕绉� + var name = System.currentTimeMillis().toString() + val mimeType = ImageUtil.getImageTypeWithMime(resource.absolutePath) + name = "$name.$mimeType" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // 澶т簬绛変簬29鐗堟湰鐨勪繚瀛樻柟娉� + val resolver = context.contentResolver + // 璁剧疆鏂囦欢鍙傛暟鍒癈ontentValues涓� + val values = ContentValues() + values.put(MediaStore.Images.Media.DISPLAY_NAME, name) + values.put(MediaStore.Images.Media.DESCRIPTION, name) + values.put(MediaStore.Images.Media.MIME_TYPE, "image/$mimeType") + values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/" + downloadFolderName + "/") + val insertUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + var inputStream: BufferedInputStream? = null + var os: OutputStream? = null + try { + inputStream = BufferedInputStream(FileInputStream(resource.absolutePath)) + os = insertUri?.let { resolver.openOutputStream(it) } + os?.let { + val buffer = ByteArray(1024 * 4) + var len: Int + while (inputStream.read(buffer).also { len = it } != -1) { + os.write(buffer, 0, len) + } + os.flush() + } + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadSuccess(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString( + R.string.toast_save_success, + "$downloadFolderName/$name" + ) + ) + } + insertUri?.refresh(resolver) + } catch (e: IOException) { + e.printStackTrace() + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadFailed(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_failed) + ) + } + } finally { + try { + os?.close() + } catch (e: IOException) { + e.printStackTrace() + } + try { + inputStream?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + } else { + // 浣庝簬29鐗堟湰鐨勪繚瀛樻柟娉� + val path = Environment.getExternalStorageDirectory().toString() + "/" + downloadFolderName + "/" + createFileByDeleteOldFile(path + name) + val result = copyFile(resource, path, name) + if (result) { + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadSuccess(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_success, path) + ) + } + SingleMediaScanner(context, path + name, object : SingleMediaScanner.ScanListener { + override fun onScanFinish() { + // scanning... + } + }) + } else { + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadFailed(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_failed) + ) + } + } + } + } + + fun downloadVideo(context: Activity, currentItem: Int, url: String?) { + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadStart(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_start_download) + ) + } + Thread { + val saveDir = getAvailableCacheDir(context)?.absolutePath + File.separator + "video/" + val fileFullName = System.currentTimeMillis().toString() + ".mp4" + val downloadFile = downloadFile(url, fileFullName, saveDir) + Handler(Looper.getMainLooper()).post { + if (downloadFile != null && downloadFile.exists() && downloadFile.length() > 0) { + // 閫氳繃urlConn涓嬭浇瀹屾垚 + saveVideo(context, downloadFile, currentItem) + } else { + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadFailed(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_failed) + ) + } + } + } + }.start() + } + + private fun saveVideo(context: Activity, resource: File, currentItem: Int) { + // 浼犲叆鐨勪繚瀛樻枃浠跺す鍚� + val downloadFolderName = ImagePreview.instance.folderName + // 淇濆瓨鐨勫悕绉� + var name = System.currentTimeMillis().toString() + ".mp4" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // 澶т簬绛変簬29鐗堟湰鐨勪繚瀛樻柟娉� + val resolver = context.contentResolver + // 璁剧疆鏂囦欢鍙傛暟鍒癈ontentValues涓� + val values = ContentValues() + values.put(MediaStore.Video.Media.DISPLAY_NAME, name) + values.put(MediaStore.Video.Media.DESCRIPTION, name) + values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + "/" + downloadFolderName + "/") + val insertUri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values) + var inputStream: BufferedInputStream? = null + var os: OutputStream? = null + try { + inputStream = BufferedInputStream(FileInputStream(resource.absolutePath)) + os = insertUri?.let { resolver.openOutputStream(it) } + os?.let { + val buffer = ByteArray(1024 * 4) + var len: Int + while (inputStream.read(buffer).also { len = it } != -1) { + os.write(buffer, 0, len) + } + os.flush() + } + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadSuccess(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString( + R.string.toast_save_success, + "$downloadFolderName/$name" + ) + ) + } + insertUri?.refresh(resolver) + } catch (e: IOException) { + e.printStackTrace() + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadFailed(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_failed) + ) + } + } finally { + try { + os?.close() + } catch (e: IOException) { + e.printStackTrace() + } + try { + inputStream?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + } else { + // 浣庝簬29鐗堟湰鐨勪繚瀛樻柟娉� + val path = Environment.getExternalStorageDirectory().toString() + "/" + downloadFolderName + "/" + createFileByDeleteOldFile(path + name) + val result = copyFile(resource, path, name) + if (result) { + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadSuccess(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_success, path) + ) + } + SingleMediaScanner(context, path + name, object : SingleMediaScanner.ScanListener { + override fun onScanFinish() { + // scanning... + } + }) + } else { + if (ImagePreview.instance.downloadListener != null) { + ImagePreview.instance.downloadListener?.onDownloadFailed(context, currentItem) + } else { + ToastUtil.instance.showShort( + context, + context.getString(R.string.toast_save_failed) + ) + } + } + } + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/tool/image/ImageUtil.kt b/library/src/main/java/cc/shinichi/library/tool/image/ImageUtil.kt new file mode 100644 index 0000000..a0c5194 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/tool/image/ImageUtil.kt @@ -0,0 +1,410 @@ +package cc.shinichi.library.tool.image + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.text.TextUtils +import androidx.annotation.RequiresApi +import androidx.exifinterface.media.ExifInterface +import cc.shinichi.library.tool.common.PhoneUtil +import cc.shinichi.library.tool.common.SLog +import java.io.* +import java.util.* +import kotlin.math.max + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.drawlongpicturedemo.util + * create at 2018/8/28 10:46 + * description: + */ +object ImageUtil { + private const val TAG = "ImageUtil" + + @RequiresApi(Build.VERSION_CODES.Q) + fun Uri.refresh( + resolver: ContentResolver, + ) { + val imageValues = ContentValues() + // Android Q娣诲姞浜咺S_PENDING鐘舵�侊紝涓�0鏃跺叾浠栧簲鐢ㄦ墠鍙 + imageValues.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(this, imageValues, null, null) + } + + fun getBitmapDegree(path: String): Int { + var degree = 0 + try { + val exifInterface = ExifInterface(path) + val orientation = exifInterface.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + degree = when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + else -> 0 + } + } catch (e: IOException) { + e.printStackTrace() + } + return degree + } + + private fun rotateBitmapByDegree(bm: Bitmap, degree: Int): Bitmap { + var returnBm: Bitmap? = null + val matrix = Matrix() + matrix.postRotate(degree.toFloat()) + try { + returnBm = Bitmap.createBitmap(bm, 0, 0, bm.width, bm.height, matrix, true) + } catch (e: OutOfMemoryError) { + e.printStackTrace() + } + if (returnBm == null) { + returnBm = bm + } + if (bm != returnBm) { + bm.recycle() + } + return returnBm + } + + private fun getOrientation(imagePath: String): Int { + try { + val exifInterface = ExifInterface(imagePath) + return when (exifInterface.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + )) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + else -> 0 + } + } catch (e: Exception) { + e.printStackTrace() + } + return 0 + } + + fun getWidthHeight(imagePath: String): IntArray { + if (imagePath.isEmpty()) { + return intArrayOf(0, 0) + } + var srcWidth = -1 + var srcHeight = -1 + + // 浣跨敤绗竴绉嶆柟寮忚幏鍙栧師濮嬪浘鐗囩殑瀹介珮 + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + try { + val originBitmap = BitmapFactory.decodeFile(imagePath, options) + srcWidth = options.outWidth + srcHeight = options.outHeight + if (originBitmap != null && !originBitmap.isRecycled) { + originBitmap.recycle() + } + } catch (e: Exception) { + e.printStackTrace() + } + + // 浣跨敤绗簩绉嶆柟寮忚幏鍙栧師濮嬪浘鐗囩殑瀹介珮 + if (srcWidth <= 0 || srcHeight <= 0) { + try { + val exifInterface = ExifInterface(imagePath) + srcHeight = exifInterface.getAttributeInt( + ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.ORIENTATION_NORMAL + ) + srcWidth = exifInterface.getAttributeInt( + ExifInterface.TAG_IMAGE_WIDTH, + ExifInterface.ORIENTATION_NORMAL + ) + } catch (e: IOException) { + e.printStackTrace() + } + } + + // 浣跨敤绗笁绉嶆柟寮忚幏鍙栧師濮嬪浘鐗囩殑瀹介珮 + if (srcWidth <= 0 || srcHeight <= 0) { + val bitmap2 = BitmapFactory.decodeFile(imagePath) + if (bitmap2 != null) { + srcWidth = bitmap2.width + srcHeight = bitmap2.height + try { + if (!bitmap2.isRecycled) { + bitmap2.recycle() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + val orient = getOrientation(imagePath) + return if (orient == 90 || orient == 270) { + intArrayOf(srcHeight, srcWidth) + } else intArrayOf(srcWidth, srcHeight) + } + + private fun isTablet(context: Context): Boolean { + return context.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK >= Configuration.SCREENLAYOUT_SIZE_LARGE + } + + private fun isLandscape(context: Context): Boolean { + val phoneRatio = PhoneUtil.getPhoneRatio(context.applicationContext) + return phoneRatio <= 1f + } + + fun isTabletOrLandscape(context: Context): Boolean { + return isTablet(context) or isLandscape(context) + } + + fun isLongImage(imagePath: String): Boolean { + val wh = getWidthHeight(imagePath) + val w = wh[0].toFloat() + val h = wh[1].toFloat() + val imageRatio = h / w + val isLongImage = h > w && imageRatio >= 3 + SLog.d(TAG, "isLongImage = $isLongImage") + return isLongImage + } + + fun isWideImage(imagePath: String): Boolean { + val wh = getWidthHeight(imagePath) + val w = wh[0].toFloat() + val h = wh[1].toFloat() + val imageRatio = w / h + val isWideImage = w > h && imageRatio >= 3 + SLog.d(TAG, "isWideImage = $isWideImage") + return isWideImage + } + + fun getStandardImageMaxZoomScale(context: Context, imagePath: String): Float { + val wh = getWidthHeight(imagePath) + val imageWid = wh[0].toFloat() + val phoneWid = PhoneUtil.getPhoneWid(context.applicationContext).toFloat() + return (phoneWid * 4f / imageWid).coerceAtLeast(4f) + } + + fun getStandardImageDoubleScale(context: Context, imagePath: String): Float { + val widthHeight = getWidthHeight(imagePath) + val imageWid = widthHeight[0].toFloat() + val imageHei = widthHeight[1].toFloat() + val phoneHei = PhoneUtil.getPhoneHei(context.applicationContext).toFloat() + if (imageWid > imageHei) { + // 瀹藉浘锛屽弻鍑绘斁澶у埌楂樺害閾烘弧 + return phoneHei / imageHei + } else { + return getStandardImageMaxZoomScale(context, imagePath) / 2f + } + } + + fun getLongImageMaxZoomScale(context: Context, imagePath: String): Float { + val wh = getWidthHeight(imagePath) + val imageWid = wh[0].toFloat() + val imageHei = wh[1].toFloat() + val phoneWid = PhoneUtil.getPhoneWid(context.applicationContext).toFloat() + return max(imageHei / imageWid, phoneWid * 2f / imageWid) + } + + fun getLongImageDoubleZoomScale(context: Context, imagePath: String): Float { + val wh = getWidthHeight(imagePath) + val imageWid = wh[0].toDouble() + val phoneWid = PhoneUtil.getPhoneWid(context.applicationContext).toDouble() + return (phoneWid / imageWid).toFloat() + } + + fun getLongImageFillWidthScale(context: Context, imagePath: String): Float { + val wh = getWidthHeight(imagePath) + val imageWid = wh[0].toDouble() + val phoneWid = PhoneUtil.getPhoneWid(context.applicationContext).toDouble() + return (phoneWid / imageWid).toFloat() + } + + fun getWideImageMaxZoomScale(context: Context, imagePath: String): Float { + val wh = getWidthHeight(imagePath) + val imageWid = wh[0].toFloat() + val imageHei = wh[1].toFloat() + val phoneHei = PhoneUtil.getPhoneHei(context.applicationContext).toFloat() + return max(imageWid / imageHei, phoneHei * 2f / imageHei) + } + + fun getWideImageDoubleScale(context: Context, imagePath: String): Float { + val wh = getWidthHeight(imagePath) + val imageHei = wh[1].toFloat() + val phoneHei = PhoneUtil.getPhoneHei(context.applicationContext).toFloat() + return phoneHei / imageHei + } + + fun getImageBitmap(srcPath: String?, degree: Int): Bitmap? { + var degree = degree + var isOOM = false + val newOpts = BitmapFactory.Options() + newOpts.inJustDecodeBounds = true + var bitmap = BitmapFactory.decodeFile(srcPath, newOpts) + newOpts.inJustDecodeBounds = false + val be = 1f + newOpts.inSampleSize = be.toInt() + newOpts.inPreferredConfig = Bitmap.Config.RGB_565 + newOpts.inDither = false + newOpts.inPurgeable = true + newOpts.inInputShareable = true + if (bitmap != null && !bitmap.isRecycled) { + bitmap.recycle() + } + try { + bitmap = BitmapFactory.decodeFile(srcPath, newOpts) + } catch (e: OutOfMemoryError) { + isOOM = true + if (bitmap != null && !bitmap.isRecycled) { + bitmap.recycle() + } + Runtime.getRuntime().gc() + } catch (e: Exception) { + isOOM = true + Runtime.getRuntime().gc() + } + if (isOOM) { + try { + bitmap = BitmapFactory.decodeFile(srcPath, newOpts) + } catch (e: Exception) { + newOpts.inPreferredConfig = Bitmap.Config.RGB_565 + bitmap = BitmapFactory.decodeFile(srcPath, newOpts) + } + } + if (bitmap != null) { + if (degree == 90) { + degree += 180 + } + bitmap = rotateBitmapByDegree(bitmap, degree) + val ttHeight = 1080 * bitmap.height / bitmap.width + if (bitmap.width >= 1080) { + bitmap = zoomBitmap(bitmap, 1080, ttHeight) + } + } + return bitmap + } + + private fun zoomBitmap(bitmap: Bitmap, width: Int, height: Int): Bitmap { + val w = bitmap.width + val h = bitmap.height + val matrix = Matrix() + val scaleWidth = width.toFloat() / w + val scaleHeight = height.toFloat() / h + matrix.postScale(scaleWidth, scaleHeight) + return Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true) + } + + fun getImageTypeWithMime(path: String): String { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(path, options) + var type = options.outMimeType + SLog.d(TAG, "getImageTypeWithMime: path = $path, type1 = $type") + // 鈥漣mage/png鈥濄�佲�漣mage/jpeg鈥濄�佲�漣mage/gif鈥� + type = if (TextUtils.isEmpty(type)) { + "" + } else { + type.substring(6) + } + SLog.d(TAG, "getImageTypeWithMime: path = $path, type2 = $type") + return type + } + + fun isAnimWebp(url: String, path: String): Boolean { + if (!isWebpImageWithMime(url, path)) { + return false + } + var result = false + val br: BufferedReader + var line: String + // 璇诲彇path涓篒nputStream + val `is`: InputStream = FileInputStream(path) + br = BufferedReader(InputStreamReader(`is`)) + var count = 0 + while (br.readLine().also { line = it } != null) { + // 璇诲彇5琛岋紝濡傛灉鍏朵腑鍖呭惈"ANIM"鍒欎负鍔ㄥ浘 + if (line.contains("ANIM")) { + result = true + } + if (count++ >= 5) { + break + } + } + SLog.d(TAG, "isAnimWebp: result = $result") + return result + } + + fun isAnimImageWithMime(url: String, path: String): Boolean { + return "gif".equals( + getImageTypeWithMime(path), + ignoreCase = true + ) || url.toLowerCase(Locale.CHINA).endsWith("gif") + || isAnimWebp(url, path) + } + + fun isPngImageWithMime(url: String, path: String): Boolean { + return "png".equals( + getImageTypeWithMime(path), + ignoreCase = true + ) || url.toLowerCase(Locale.CHINA).endsWith("png") + } + + fun isJpegImageWithMime(url: String, path: String): Boolean { + return ("jpeg".equals(getImageTypeWithMime(path), ignoreCase = true) || "jpg".equals( + getImageTypeWithMime(path), + ignoreCase = true + ) + || url.toLowerCase(Locale.CHINA).endsWith("jpeg") || url.toLowerCase(Locale.CHINA) + .endsWith("jpg")) + } + + fun isBmpImageWithMime(url: String, path: String): Boolean { + return "bmp".equals( + getImageTypeWithMime(path), + ignoreCase = true + ) || url.toLowerCase(Locale.CHINA).endsWith("bmp") + } + + fun isWebpImageWithMime(url: String, path: String): Boolean { + return "webp".equals(getImageTypeWithMime(path), ignoreCase = true) || url.toLowerCase( + Locale.CHINA + ).endsWith("webp") + } + + fun isHeifImageWithMime(url: String, path: String): Boolean { + return "heif".equals(getImageTypeWithMime(path), ignoreCase = true) + || url.toLowerCase(Locale.CHINA).endsWith("heif") || url.toLowerCase(Locale.CHINA) + .endsWith("heic") + } + + fun isStaticImage(url: String, path: String): Boolean { + val isWebpImageWithMime = isWebpImageWithMime(url, path) + SLog.d(TAG, "isStaticImage: isWebpImageWithMime = $isWebpImageWithMime") + if (isWebpImageWithMime) { + val animWebp = isAnimWebp(url, path) + SLog.d(TAG, "isStaticImage: animWebp = $animWebp") + return !animWebp + } + val jpegImageWithMime = isJpegImageWithMime(url, path) + SLog.d(TAG, "isStaticImage: jpegImageWithMime = $jpegImageWithMime") + val pngImageWithMime = isPngImageWithMime(url, path) + SLog.d(TAG, "isStaticImage: pngImageWithMime = $pngImageWithMime") + val bmpImageWithMime = isBmpImageWithMime(url, path) + SLog.d(TAG, "isStaticImage: bmpImageWithMime = $bmpImageWithMime") + val heifImageWithMime = isHeifImageWithMime(url, path) + SLog.d(TAG, "isStaticImage: heifImageWithMime = $heifImageWithMime") + val animImageWithMime = isAnimImageWithMime(url, path) + SLog.d(TAG, "isStaticImage: animImageWithMime = $animImageWithMime") + return (jpegImageWithMime || pngImageWithMime || bmpImageWithMime || heifImageWithMime) && !animImageWithMime + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/HackyViewPager.kt b/library/src/main/java/cc/shinichi/library/view/HackyViewPager.kt new file mode 100644 index 0000000..1c45358 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/HackyViewPager.kt @@ -0,0 +1,37 @@ +package cc.shinichi.library.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +/** + * Hacky fix for Issue #4 and + * http://code.google.com/p/android/issues/detail?id=18990 + * + * + * ScaleGestureDetector seems to mess up the touch events, which means that + * ViewGroups which make use of onInterceptTouchEvent throw a lot of + * IllegalArgumentException: pointerIndex out of range. + * + * + * There's not much I can do in my code for now, but we can mask the result by + * just catching the problem and ignoring it. + * + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +class HackyViewPager : ViewPager { + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {} + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + return try { + super.onInterceptTouchEvent(ev) + } catch (e: IllegalArgumentException) { + false + } + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/ImagePreviewActivity.kt b/library/src/main/java/cc/shinichi/library/view/ImagePreviewActivity.kt new file mode 100644 index 0000000..aaabbd3 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/ImagePreviewActivity.kt @@ -0,0 +1,755 @@ +package cc.shinichi.library.view + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Message +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.mediacodec.MediaCodecInfo +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.mediacodec.MediaCodecUtil +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.ParametersBuilder +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import cc.shinichi.library.GlobalContext +import cc.shinichi.library.ImagePreview +import cc.shinichi.library.R +import cc.shinichi.library.bean.ImageInfo +import cc.shinichi.library.bean.Type +import cc.shinichi.library.glide.FileTarget +import cc.shinichi.library.glide.ImageLoader +import cc.shinichi.library.glide.progress.OnProgressListener +import cc.shinichi.library.glide.progress.ProgressManager.addListener +import cc.shinichi.library.tool.common.DeviceUtil +import cc.shinichi.library.tool.common.HandlerHolder +import cc.shinichi.library.tool.common.HttpUtil +import cc.shinichi.library.tool.common.NetworkUtil +import cc.shinichi.library.tool.common.PhoneUtil +import cc.shinichi.library.tool.common.SLog +import cc.shinichi.library.tool.common.ToastUtil +import cc.shinichi.library.tool.common.UIUtil +import cc.shinichi.library.tool.image.DownloadUtil +import cc.shinichi.library.view.listener.OnFinishListener +import com.bumptech.glide.Glide + + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +class ImagePreviewActivity : AppCompatActivity(), Handler.Callback, View.OnClickListener, + OnFinishListener { + + private lateinit var context: Activity + private lateinit var handlerHolder: HandlerHolder + private val imageInfoList: MutableList<ImageInfo> = mutableListOf() + private val fragmentList: MutableList<ImagePreviewFragment> = mutableListOf() + + lateinit var parentView: View + lateinit var viewPager: HackyViewPager + lateinit var consControllerOverlay: ConstraintLayout + private lateinit var tvIndicator: TextView + private lateinit var consBottomController: ConstraintLayout + + private lateinit var fmImageShowOriginContainer: FrameLayout + private lateinit var fmCenterProgressContainer: FrameLayout + private lateinit var btnShowOrigin: Button + private lateinit var imgDownload: ImageView + private lateinit var imgCloseButton: ImageView + private lateinit var rootView: View + private lateinit var progressParentLayout: View + + private lateinit var imagePreviewAdapter: ImagePreviewAdapter + + private var isShowDownButton = false + private var isShowCloseButton = false + private var isShowOriginButton = false + private var isShowIndicator = false + private var isUserCustomProgressView = false + private var indicatorStatus = false + private var originalStatus = false + private var downloadButtonStatus = false + private var closeButtonStatus = false + + private var currentItem = 0 + private var currentItemOriginPathUrl: String? = "" + private var lastProgress = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + overridePendingTransition(R.anim.fade_in, R.anim.fade_out) + + parentView = View.inflate(this, ImagePreview.instance.previewLayoutResId, null) + setContentView(parentView) + ImagePreview.instance.onCustomLayoutCallback?.onLayout(this, parentView) + + transparentStatusBar() + transparentNavBar() + + context = this + handlerHolder = HandlerHolder(this) + imageInfoList.clear() + imageInfoList.addAll(ImagePreview.instance.getImageInfoList()) + if (imageInfoList.isEmpty()) { + finish() + return + } + + // 鍥炶皟 + ImagePreview.instance.setOnFinishListener(this) + + // 鎾斁鍣ㄥ垵濮嬪寲 + initExoPlayer() + + currentItem = ImagePreview.instance.index + isShowDownButton = ImagePreview.instance.isShowDownButton + isShowCloseButton = ImagePreview.instance.isShowCloseButton + isShowIndicator = ImagePreview.instance.isShowIndicator + currentItemOriginPathUrl = imageInfoList[currentItem].originUrl + isShowOriginButton = ImagePreview.instance.isShowOriginButton(currentItem) + if (isShowOriginButton) { + // 妫�鏌ョ紦瀛樻槸鍚﹀瓨鍦� + checkCache(currentItemOriginPathUrl) + } + + rootView = findViewById(R.id.rootView) + viewPager = findViewById(R.id.viewPager) + consControllerOverlay = findViewById(R.id.consControllerOverlay) + tvIndicator = findViewById(R.id.tv_indicator) + consBottomController = findViewById<ConstraintLayout>(R.id.consBottomController) + fmImageShowOriginContainer = findViewById(R.id.fm_image_show_origin_container) + fmCenterProgressContainer = findViewById(R.id.fm_center_progress_container) + + // 椤堕儴鍜屽簳閮╩argin + refreshUIMargin() + + fmImageShowOriginContainer.visibility = View.GONE + fmCenterProgressContainer.visibility = View.GONE + val progressLayoutId = ImagePreview.instance.progressLayoutId + // != -1 鍗崇敤鎴疯嚜瀹氫箟浜唙iew + if (progressLayoutId != -1) { + // add鐢ㄦ埛鑷畾涔夌殑view鍒癴rameLayout涓紝鍥炶皟杩涘害鍜寁iew + progressParentLayout = + View.inflate(context, ImagePreview.instance.progressLayoutId, null) + fmCenterProgressContainer.removeAllViews() + fmCenterProgressContainer.addView(progressParentLayout) + isUserCustomProgressView = true + } else { + // 浣跨敤榛樿鐨則extView杩涜鐧惧垎姣旂殑鏄剧ず + isUserCustomProgressView = false + } + btnShowOrigin = findViewById(R.id.btn_show_origin) + imgDownload = findViewById(R.id.img_download) + imgCloseButton = findViewById(R.id.imgCloseButton) + imgDownload.setImageResource(ImagePreview.instance.downIconResId) + imgCloseButton.setImageResource(ImagePreview.instance.closeIconResId) + + // 鍏抽棴椤甸潰鎸夐挳 + imgCloseButton.setOnClickListener(this) + // 鏌ョ湅涓庡師鍥炬寜閽� + btnShowOrigin.setOnClickListener(this) + // 涓嬭浇鍥剧墖鎸夐挳 + imgDownload.setOnClickListener(this) + indicatorStatus = if (!isShowIndicator) { + tvIndicator.visibility = View.GONE + false + } else { + if (imageInfoList.size > 1) { + tvIndicator.visibility = View.VISIBLE + true + } else { + tvIndicator.visibility = View.GONE + false + } + } + // 璁剧疆椤堕儴鎸囩ず鍣ㄨ儗鏅痵hape + if (ImagePreview.instance.indicatorShapeResId > 0) { + tvIndicator.setBackgroundResource(ImagePreview.instance.indicatorShapeResId) + } + // 璁剧疆鍏抽棴鍜屼笅杞界殑shape + if (ImagePreview.instance.closeIconBackgroundResId > 0) { + imgCloseButton.setBackgroundResource(ImagePreview.instance.closeIconBackgroundResId) + } + if (ImagePreview.instance.downIconBackgroundResId > 0) { + imgDownload.setBackgroundResource(ImagePreview.instance.downIconBackgroundResId) + } + downloadButtonStatus = if (isShowDownButton) { + imgDownload.visibility = View.VISIBLE + true + } else { + imgDownload.visibility = View.GONE + false + } + closeButtonStatus = if (isShowCloseButton) { + imgCloseButton.visibility = View.VISIBLE + true + } else { + imgCloseButton.visibility = View.GONE + false + } + + // 鏇存柊杩涘害鎸囩ず鍣� + tvIndicator.text = String.format( + getString(R.string.indicator), + (currentItem + 1).toString(), + (imageInfoList.size).toString() + ) + + // 閫傞厤鍣� + fragmentList.clear() + for ((index, info) in imageInfoList.withIndex()) { + fragmentList.add( + ImagePreviewFragment.newInstance( + this@ImagePreviewActivity, + index, + info + ) + ) + } + imagePreviewAdapter = ImagePreviewAdapter(supportFragmentManager, fragmentList) + viewPager.adapter = imagePreviewAdapter + viewPager.offscreenPageLimit = 1 + viewPager.setCurrentItem(currentItem, false) + + viewPager.addOnPageChangeListener(object : OnPageChangeListener { + override fun onPageSelected(position: Int) { + currentItem = position + // select鍥炶皟 + ImagePreview.instance.bigImagePageChangeListener?.onPageSelected(currentItem) + + // 鍒ゆ柇鏄惁灞曠ず鏌ョ湅鍘熷浘鎸夐挳 + if (imageInfoList[currentItem].type == Type.IMAGE) { + currentItemOriginPathUrl = imageInfoList[currentItem].originUrl + isShowOriginButton = ImagePreview.instance.isShowOriginButton(currentItem) + if (isShowOriginButton) { + // 妫�鏌ョ紦瀛樻槸鍚﹀瓨鍦� + checkCache(currentItemOriginPathUrl) + } else { + gone() + } + } else { + gone() + } + + // 鏇存柊杩涘害鎸囩ず鍣� + tvIndicator.text = String.format( + getString(R.string.indicator), + (currentItem + 1).toString(), + (imageInfoList.size).toString() + ) + + // 濡傛灉鏄嚜瀹氫箟鐧惧垎姣旇繘搴iew锛屾瘡娆″垏鎹㈤兘鍏堥殣钘忥紝骞堕噸缃櫨鍒嗘瘮 + if (isUserCustomProgressView) { + fmCenterProgressContainer.visibility = View.GONE + lastProgress = 0 + } + + // 璋冪敤闈炲綋鍓嶉〉鐨凢ragment鐨勬柟娉� + for (i in fragmentList.indices) { + if (i != currentItem) { + fragmentList[i].onUnSelected() + } + } + // 璋冪敤褰撳墠椤电殑Fragment鐨勬柟娉� + fragmentList[currentItem].onSelected() + } + + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int + ) { + ImagePreview.instance.bigImagePageChangeListener?.onPageScrolled( + position, + positionOffset, + positionOffsetPixels + ) + } + + override fun onPageScrollStateChanged(state: Int) { + ImagePreview.instance.bigImagePageChangeListener?.onPageScrollStateChanged(state) + } + }) + } + + private fun refreshUIMargin() { + val layoutParamsIndicator = tvIndicator.layoutParams as ConstraintLayout.LayoutParams + val layoutParams = consBottomController.layoutParams as ConstraintLayout.LayoutParams + // 鑾峰彇褰撳墠灞忓箷鏂瑰悜 + val orientation = resources.configuration.orientation + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + // 妯睆 + // tvIndicator top margin + val topMargin = UIUtil.dp2px(context, 20f) + layoutParamsIndicator.setMargins(0, topMargin, 0, 0) + + // consBottomController bottom margin + val bottomMargin = UIUtil.dp2px(context, 20f) + layoutParams.setMargins(0, 0, 0, bottomMargin) + } else { + // 绔栧睆 + // tvIndicator top margin + val topMargin = PhoneUtil.getStatusBarHeight(context) + UIUtil.dp2px(context, 20f) + layoutParamsIndicator.setMargins(0, topMargin, 0, 0) + + // consBottomController bottom margin + val bottomMargin = PhoneUtil.getNavBarHeight(context) + UIUtil.dp2px(context, 20f) + layoutParams.setMargins(0, 0, 0, bottomMargin) + } + tvIndicator.layoutParams = layoutParamsIndicator + consBottomController.layoutParams = layoutParams + } + + @OptIn(UnstableApi::class) + private fun initExoPlayer() { + } + + + + + + + @OptIn(UnstableApi::class) + fun getExoPlayer(): ExoPlayer { + val trackSelector = DefaultTrackSelector(context) + trackSelector.parameters = ParametersBuilder(context) + .setMaxVideoSize(1280, 720) // 闄愬埗鏈�澶у垎杈ㄧ巼涓� 1920x1080 + .build() + + // 鑷畾涔� MediaCodecSelector 瀹炵幇 + val customMediaCodecSelector = object : MediaCodecSelector { + + // 鍒ゆ柇璁惧鏄惁鏀寔纭欢瑙g爜 + private fun isHardwareDecoderAvailable(mimeType: String): Boolean { + return try { + // 鑾峰彇璁惧鏀寔鐨勮В鐮佸櫒淇℃伅 + val codecInfos: List<MediaCodecInfo> = MediaCodecSelector.DEFAULT.getDecoderInfos(mimeType, false, false) + + // 鍒ゆ柇鏄惁瀛樺湪纭欢瑙g爜鍣紙鍚嶇О涓寘鍚� "OMX" 涓�鑸〃绀虹‖浠惰В鐮佸櫒锛� + codecInfos.any { it.name.contains("OMX") } + } catch (e: MediaCodecUtil.DecoderQueryException) { + e.printStackTrace() + false + } + } + + override fun getDecoderInfos( + mimeType: String, + requiresSecureDecoder: Boolean, + requiresTunnelingDecoder: Boolean + ): List<MediaCodecInfo> { + return try { + // 棣栧厛鍒ゆ柇鏄惁鏀寔纭欢瑙g爜 + val codecInfos = MediaCodecSelector.DEFAULT.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder) + + if (isHardwareDecoderAvailable(mimeType)) { + // 濡傛灉鏀寔纭欢瑙g爜锛屼紭鍏堜娇鐢ㄧ‖浠惰В鐮佸櫒 + codecInfos.filter { it.name.contains("OMX") } // 鍙�夋嫨纭欢瑙g爜鍣� + } else { + // 濡傛灉纭欢瑙g爜鍣ㄤ笉鍙敤锛屽垯閫夋嫨杞欢瑙g爜鍣� + codecInfos.filter { !it.name.contains("OMX") } // 鍙�夋嫨杞欢瑙g爜鍣� + } + } catch (e: MediaCodecUtil.DecoderQueryException) { + e.printStackTrace() + emptyList() // 鍑虹幇寮傚父鏃惰繑鍥炵┖鍒楄〃 + } + } + } + + val exoPlayer = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(GlobalContext.getCacheDataSourceFactory())) + .setRenderersFactory(DefaultRenderersFactory(context).apply { + // 閰嶇疆瑙g爜鍣ㄩ�夋嫨 + setMediaCodecSelector(customMediaCodecSelector) + }) + .setTrackSelector(trackSelector) + .build() + + + return exoPlayer + } + + /** + * 鍒涘缓涓�涓‖瑙g爜鍣� + */ + @OptIn(UnstableApi::class) + fun getNoOMXExoPlayer():ExoPlayer{ + val trackSelector = DefaultTrackSelector(context) + trackSelector.parameters = ParametersBuilder(context) + .setMaxVideoSize(1280, 720) // 闄愬埗鏈�澶у垎杈ㄧ巼涓� 1920x1080 + .build() + val customMediaCodecSelector = object : MediaCodecSelector { + + override fun getDecoderInfos( + mimeType: String, + requiresSecureDecoder: Boolean, + requiresTunnelingDecoder: Boolean + ): List<MediaCodecInfo> { + return try { + // 棣栧厛鍒ゆ柇鏄惁鏀寔纭欢瑙g爜 + val codecInfos = MediaCodecSelector.DEFAULT.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder) + + // 濡傛灉纭欢瑙g爜鍣ㄤ笉鍙敤锛屽垯閫夋嫨杞欢瑙g爜鍣� + codecInfos.filter { !it.name.contains("OMX") } // 鍙�夋嫨杞欢瑙g爜鍣� + + } catch (e: MediaCodecUtil.DecoderQueryException) { + e.printStackTrace() + emptyList() // 鍑虹幇寮傚父鏃惰繑鍥炵┖鍒楄〃 + } + } + } + + val exoPlayer = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(GlobalContext.getCacheDataSourceFactory())) + .setRenderersFactory(DefaultRenderersFactory(context).apply { + // 閰嶇疆瑙g爜鍣ㄩ�夋嫨 + setMediaCodecSelector(customMediaCodecSelector) + }) + .setTrackSelector(trackSelector) + .build() + return exoPlayer + } + + + /** + * 涓嬭浇褰撳墠鍥剧墖/瑙嗛鍒癝D鍗� + */ + private fun downloadCurrentImg() { + if (imageInfoList[currentItem].type == Type.IMAGE) { + DownloadUtil.downloadPicture(context, currentItem, currentItemOriginPathUrl) + } else if (imageInfoList[currentItem].type == Type.VIDEO) { + DownloadUtil.downloadVideo(context, currentItem, currentItemOriginPathUrl) + } + } + + override fun onFinish() { + onBackPressed() + } + + override fun onBackPressed() { + finish() + } + + override fun finish() { + for (fragment in fragmentList) { + fragment.onRelease() + } + ImagePreview.instance.onPageFinishListener?.onFinish(this) + ImagePreview.instance.reset() + super.finish() + } + + override fun onDestroy() { + super.onDestroy() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // 鍒ゆ柇灞忓箷鏂瑰悜鍙樺寲 + // 椤堕儴鍜屽簳閮╩argin + refreshUIMargin() + } + + private fun convertPercentToBlackAlphaColor(percent: Float): Int { + val realPercent = 1f.coerceAtMost(0f.coerceAtLeast(percent)) + val intAlpha = (realPercent * 255).toInt() + val stringAlpha = Integer.toHexString(intAlpha).lowercase() + val color = "#" + (if (stringAlpha.length < 2) "0" else "") + stringAlpha + "000000" + return Color.parseColor(color) + } + + fun setAlpha(alpha: Float) { + val colorId = convertPercentToBlackAlphaColor(alpha) + rootView.setBackgroundColor(colorId) + if (alpha >= 1) { + if (indicatorStatus) { + tvIndicator.visibility = View.VISIBLE + } + consBottomController.visibility = View.VISIBLE + } else { + tvIndicator.visibility = View.GONE + consBottomController.visibility = View.GONE + } + } + + override fun handleMessage(msg: Message): Boolean { + if (msg.what == 0) { + // 鐐瑰嚮鏌ョ湅鍘熷浘鎸夐挳锛屽紑濮嬪姞杞藉師鍥� + val path = imageInfoList[currentItem].originUrl + visible() + if (isUserCustomProgressView) { + gone() + } else { + btnShowOrigin.text = "0 %" + } + if (checkCache(path)) { + val message = handlerHolder.obtainMessage() + val bundle = Bundle() + bundle.putString("url", path) + message.what = 1 + message.obj = bundle + handlerHolder.sendMessage(message) + return true + } + loadOriginImage(path) + } else if (msg.what == 1) { + // 鍔犺浇瀹屾垚 + val bundle = msg.obj as Bundle + val url = HttpUtil.decode(bundle.getString("url", "")) + gone() + if (currentItem == getRealIndexWithPath(url)) { + if (isUserCustomProgressView) { + fmCenterProgressContainer.visibility = View.GONE + progressParentLayout.visibility = View.GONE + ImagePreview.instance.onOriginProgressListener?.finish( + this, + progressParentLayout + ) + } + fragmentList[currentItem].onOriginal() + } + } else if (msg.what == 2) { + // 鍔犺浇涓� + val bundle = msg.obj as Bundle + val url = HttpUtil.decode(bundle.getString("url", "")) + val progress = bundle.getInt("progress") + if (currentItem == getRealIndexWithPath(url)) { + if (isUserCustomProgressView) { + gone() + fmCenterProgressContainer.visibility = View.VISIBLE + progressParentLayout.visibility = View.VISIBLE + ImagePreview.instance.onOriginProgressListener?.progress( + this, + progressParentLayout, + progress + ) + } else { + visible() + btnShowOrigin.text = String.format("%s %%", progress) + } + } + } else if (msg.what == 3) { + // 闅愯棌鏌ョ湅鍘熷浘鎸夐挳 + btnShowOrigin.setText(R.string.btn_original) + fmImageShowOriginContainer.visibility = View.GONE + originalStatus = false + } else if (msg.what == 4) { + // 鏄剧ず鏌ョ湅鍘熷浘鎸夐挳 + fmImageShowOriginContainer.visibility = View.VISIBLE + originalStatus = true + } + return true + } + + private fun getRealIndexWithPath(path: String?): Int { + for (i in imageInfoList.indices) { + if (path.equals(imageInfoList[i].originUrl, ignoreCase = true)) { + return i + } + } + return 0 + } + + private fun checkCache(url: String?): Boolean { + val cacheFile = ImageLoader.getGlideCacheFile(context, url) + return if (cacheFile != null && cacheFile.exists()) { + gone() + true + } else { + // 缂撳瓨涓嶅瓨鍦� + // 濡傛灉鏄叏鑷姩妯″紡涓斿綋鍓嶆槸WiFi锛屽氨涓嶆樉绀烘煡鐪嬪師鍥炬寜閽� + if (ImagePreview.instance.loadStrategy == ImagePreview.LoadStrategy.Auto && NetworkUtil.isWiFi( + context + ) + ) { + gone() + } else { + visible() + } + false + } + } + + override fun onClick(v: View) { + val i = v.id + if (i == R.id.img_download) { + val downloadClickListener = ImagePreview.instance.downloadClickListener + if (downloadClickListener != null) { + val interceptDownload = downloadClickListener.isInterceptDownload + if (interceptDownload) { + // 鎷︽埅浜嗕笅杞斤紝涓嶆墽琛屼笅杞� + } else { + // 娌℃湁鎷︽埅涓嬭浇 + checkAndDownload() + } + ImagePreview.instance.downloadClickListener?.onClick(context, v, currentItem) + } else { + checkAndDownload() + } + } else if (i == R.id.btn_show_origin) { + handlerHolder.sendEmptyMessage(0) + } else if (i == R.id.imgCloseButton) { + finish() + } + } + + private fun checkAndDownload() { + if (DeviceUtil.isHarmonyOs()) { + val harmonyVersion = DeviceUtil.getHarmonyVersionCode() + SLog.d("checkAndDownload", "鏄缚钂欑郴缁�, harmonyVersion:$harmonyVersion") + if (harmonyVersion < 6) { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + context, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 1 + ) + } else { + // 涓嬭浇褰撳墠鍥剧墖 + downloadCurrentImg() + } + } else { + // 涓嬭浇褰撳墠鍥剧墖 + downloadCurrentImg() + } + } else { + SLog.d("checkAndDownload", "涓嶆槸楦胯挋绯荤粺") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + // Android 10 浠ヤ笅锛岄渶瑕佸姩鎬佺敵璇锋潈闄� + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + context, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 1 + ) + } else { + // 涓嬭浇褰撳墠鍥剧墖 + downloadCurrentImg() + } + } else { + // Android 10 鍙婁互涓婏紝淇濆瓨鍥剧墖涓嶉渶瑕佹潈闄� + // 涓嬭浇褰撳墠鍥剧墖 + downloadCurrentImg() + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<out String>, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // 涓嬭浇褰撳墠鍥剧墖 + downloadCurrentImg() + } else { + ToastUtil.instance.showShort( + context, + getString(R.string.toast_deny_permission_save_failed) + ) + } + } + } + + private fun gone() { + handlerHolder.sendEmptyMessage(3) + } + + private fun visible() { + handlerHolder.sendEmptyMessage(4) + } + + private fun loadOriginImage(path: String?) { + addListener(path, object : OnProgressListener { + override fun onProgress( + url: String?, + isComplete: Boolean, + percentage: Int, + bytesRead: Long, + totalBytes: Long + ) { + if (isComplete) { // 鍔犺浇瀹屾垚 + val message = handlerHolder.obtainMessage() + val bundle = Bundle() + bundle.putString("url", url) + message.what = 1 + message.obj = bundle + handlerHolder.sendMessage(message) + } else { // 鍔犺浇涓紝涓哄噺灏戝洖璋冩鏁帮紝姝ゅ鍋氬垽鏂紝濡傛灉鍜屼笂娆$殑鐧惧垎姣斾竴鑷村氨璺宠繃 + if (percentage == lastProgress) { + return + } + lastProgress = percentage + val message = handlerHolder.obtainMessage() + val bundle = Bundle() + bundle.putString("url", url) + bundle.putInt("progress", percentage) + message.what = 2 + message.obj = bundle + handlerHolder.sendMessage(message) + } + } + }) + Glide.with(context).downloadOnly().load(path).into(object : FileTarget() { + }) + } + + private fun transparentStatusBar() { + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + val option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + window.decorView.systemUiVisibility = option + window.statusBarColor = Color.TRANSPARENT + } + + private fun transparentNavBar() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + window.navigationBarColor = Color.TRANSPARENT + val decorView = window.decorView + val option = + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + decorView.systemUiVisibility = option + } + + companion object { + fun activityStart(context: Activity) { + val intent = Intent() + intent.setClass(context, ImagePreviewActivity::class.java) + context.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/ImagePreviewAdapter.kt b/library/src/main/java/cc/shinichi/library/view/ImagePreviewAdapter.kt new file mode 100644 index 0000000..d896623 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/ImagePreviewAdapter.kt @@ -0,0 +1,25 @@ +package cc.shinichi.library.view + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter + +/** + * 鏂囦欢鍚�: ImagePreviewAdapter2.java + * 浣滆��: kirito + * 鎻忚堪: ViewPager閫傞厤鍣� + * 鍒涘缓鏃堕棿: 2024/11/25 + */ +class ImagePreviewAdapter( + fragmentManager: FragmentManager, + private val fragmentList: MutableList<ImagePreviewFragment> +) : FragmentStatePagerAdapter(fragmentManager) { + + override fun getItem(position: Int): Fragment { + return fragmentList[position] + } + + override fun getCount(): Int { + return fragmentList.size + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/ImagePreviewFragment.kt b/library/src/main/java/cc/shinichi/library/view/ImagePreviewFragment.kt new file mode 100644 index 0000000..af3174a --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/ImagePreviewFragment.kt @@ -0,0 +1,934 @@ +package cc.shinichi.library.view + +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.PointF +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.SeekBar +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.PlayerView +import cc.shinichi.library.GlobalContext +import cc.shinichi.library.ImagePreview +import cc.shinichi.library.ImagePreview.LoadStrategy +import cc.shinichi.library.R +import cc.shinichi.library.bean.ImageInfo +import cc.shinichi.library.bean.Type +import cc.shinichi.library.glide.FileTarget +import cc.shinichi.library.glide.ImageLoader.getGlideCacheFile +import cc.shinichi.library.tool.common.HttpUtil.downloadFile +import cc.shinichi.library.tool.common.NetworkUtil.isWiFi +import cc.shinichi.library.tool.common.PhoneUtil +import cc.shinichi.library.tool.common.PhoneUtil.getPhoneHei +import cc.shinichi.library.tool.common.SLog +import cc.shinichi.library.tool.common.ToastUtil +import cc.shinichi.library.tool.common.UIUtil +import cc.shinichi.library.tool.file.FileUtil.Companion.getAvailableCacheDir +import cc.shinichi.library.tool.image.ImageUtil +import cc.shinichi.library.view.helper.DragCloseView +import cc.shinichi.library.view.listener.SimpleOnImageEventListener +import cc.shinichi.library.view.photoview.PhotoView +import cc.shinichi.library.view.subsampling.ImageSource +import cc.shinichi.library.view.subsampling.SubsamplingScaleImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.webp.decoder.WebpDrawable +import com.bumptech.glide.integration.webp.decoder.WebpDrawableTransformation +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Transformation +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.FitCenter +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target +import java.io.File +import java.util.Locale +import kotlin.compareTo +import kotlin.div +import kotlin.math.abs + +/** + * 鏂囦欢鍚�: ImagePreviewFragment.java + * 浣滆��: kirito + * 鎻忚堪: 鍗曚釜椤甸潰 + * 鍒涘缓鏃堕棿: 2024/11/25 + */ +class ImagePreviewFragment : Fragment() { + + /** + * 褰撳墠鏄惁鍔犺浇杩� + */ + private var mLoading = false + + private lateinit var imagePreviewActivity: ImagePreviewActivity + private lateinit var imageInfo: ImageInfo + private var position: Int = 0 + + private lateinit var dragCloseView: DragCloseView + private lateinit var imageStatic: SubsamplingScaleImageView + private lateinit var imageAnim: PhotoView + private lateinit var videoView: PlayerView + private lateinit var progressBar: ProgressBar + + private var exoPlayer: ExoPlayer? = null + + private lateinit var ivPlayButton: ImageView + private lateinit var tvPlayTime: TextView + private lateinit var seekBar: SeekBar + + private var progressHandler: Handler? = null + private var progressRunnable: Runnable? = null + private var isDragging = false + private var onPausePlaying = false + + //鏄惁鏄涓�娆℃挱鏀� + private var isFirstPlay = true + + companion object { + private const val TAG = "ImagePreviewFragment" + fun newInstance( + imagePreviewActivity: ImagePreviewActivity, + position: Int, + imageInfo: ImageInfo + ): ImagePreviewFragment { + val fragment = ImagePreviewFragment() + fragment.imagePreviewActivity = imagePreviewActivity + fragment.position = position + fragment.imageInfo = imageInfo + return fragment + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mLoading = false + val view = inflater.inflate(R.layout.sh_item_photoview, container, false) + initView(view) + return view + } + + @UnstableApi + private fun initData() { + SLog.d(TAG, "initData: position = $position") + val type = imageInfo.type + if (type == Type.IMAGE) { + initImageType() + } else if (type == Type.VIDEO) { + initVideoType() + } + } + + private fun initView(view: View) { + progressBar = view.findViewById<ProgressBar>(R.id.progress_view) + dragCloseView = view.findViewById(R.id.fingerDragHelper) + imageStatic = view.findViewById(R.id.static_view) + imageAnim = view.findViewById(R.id.anim_view) + videoView = view.findViewById(R.id.video_view) + ivPlayButton = videoView.findViewById<ImageView>(R.id.ivPlayButton) + seekBar = videoView.findViewById<SeekBar>(R.id.seekbar) + tvPlayTime = videoView.findViewById<TextView>(R.id.tvPlayTime) + val phoneHei = getPhoneHei(imagePreviewActivity.applicationContext) + // 鎵嬪娍鎷栨嫿浜嬩欢 + if (ImagePreview.instance.isEnableDragClose) { + dragCloseView.setOnAlphaChangeListener(object : DragCloseView.onAlphaChangedListener { + override fun onTranslationYChanged(event: MotionEvent?, translationY: Float) { + if (translationY > 0) { + ImagePreview.instance.onPageDragListener?.onDrag( + imagePreviewActivity, + imagePreviewActivity.parentView, + event, + translationY + ) + } else { + ImagePreview.instance.onPageDragListener?.onDragEnd( + imagePreviewActivity, + imagePreviewActivity.parentView + ) + } + val yAbs = abs(translationY) + val percent = yAbs / phoneHei + val number = 1.0f - percent + imagePreviewActivity.setAlpha(number) + if (imageAnim.visibility == View.VISIBLE) { + imageAnim.scaleY = number + imageAnim.scaleX = number + } + if (imageStatic.visibility == View.VISIBLE) { + imageStatic.scaleY = number + imageStatic.scaleX = number + } + if (videoView.visibility == View.VISIBLE) { + videoView.scaleY = number + videoView.scaleX = number + } + } + + override fun onExit() { + imagePreviewActivity.setAlpha(0f) + } + }) + } + // 鐐瑰嚮浜嬩欢(瑙嗛绫诲瀷涓嶆敮鎸佺偣鍑诲叧闂�) + imageStatic.setOnClickListener { v -> + if (ImagePreview.instance.isEnableClickClose) { + imagePreviewActivity.onBackPressed() + } + ImagePreview.instance.bigImageClickListener?.onClick(imagePreviewActivity, v, position) + } + imageAnim.setOnClickListener { v -> + if (ImagePreview.instance.isEnableClickClose) { + imagePreviewActivity.onBackPressed() + } + ImagePreview.instance.bigImageClickListener?.onClick(imagePreviewActivity, v, position) + } + ivPlayButton.setOnClickListener { + // 鎺у埗鎾斁鍜屾殏鍋� + videoView.player?.let { + if (it.isPlaying) { + // 鍘绘殏鍋滐紝鏄剧ず涓烘挱鏀惧浘鏍� + it.pause() + ivPlayButton.setImageResource(R.drawable.icon_video_play) + } else { + // 鍘绘挱鏀撅紝鏄剧ず涓烘殏鍋滃浘鏍� + // 濡傛灉杩涘害鏉″凡缁忓埌鏈�鍚庯紝閲嶆柊鎾斁 + it.play() + isFirstPlay=true + ivPlayButton.setImageResource(R.drawable.icon_video_stop) + } + } + } + // 闀挎寜浜嬩欢 + ImagePreview.instance.bigImageLongClickListener?.let { + imageStatic.setOnLongClickListener { v -> + ImagePreview.instance.bigImageLongClickListener?.onLongClick( + imagePreviewActivity, + v, + position + ) + true + } + imageAnim.setOnLongClickListener { v -> + ImagePreview.instance.bigImageLongClickListener?.onLongClick( + imagePreviewActivity, + v, + position + ) + true + } + videoView.setOnLongClickListener { v -> + ImagePreview.instance.bigImageLongClickListener?.onLongClick( + imagePreviewActivity, + v, + position + ) + true + } + } + } + + private fun initImageType() { + // 鍥剧墖绫诲瀷锛岄殣钘忚棰� + videoView.visibility = View.GONE + + val originPathUrl = imageInfo.originUrl + val thumbPathUrl = imageInfo.thumbnailUrl + + imageStatic.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) + imageStatic.setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) + imageStatic.setDoubleTapZoomDuration(ImagePreview.instance.zoomTransitionDuration) + + imageAnim.setZoomTransitionDuration(ImagePreview.instance.zoomTransitionDuration) + imageAnim.minimumScale = ImagePreview.instance.minScale + imageAnim.maximumScale = ImagePreview.instance.maxScale + imageAnim.scaleType = ImageView.ScaleType.FIT_CENTER + + // 鏍规嵁褰撳墠鍔犺浇绛栫暐鍒ゆ柇锛岄渶瑕佸姞杞界殑url鏄摢涓�涓� + var finalLoadUrl: String = "" + when (ImagePreview.instance.loadStrategy) { + LoadStrategy.Default -> { + finalLoadUrl = thumbPathUrl + } + + LoadStrategy.AlwaysOrigin -> { + finalLoadUrl = originPathUrl + } + + LoadStrategy.AlwaysThumb -> { + finalLoadUrl = thumbPathUrl + } + + LoadStrategy.NetworkAuto -> { + finalLoadUrl = if (isWiFi(imagePreviewActivity)) { + originPathUrl + } else { + thumbPathUrl + } + } + + LoadStrategy.Auto -> { + finalLoadUrl = if (isWiFi(imagePreviewActivity)) { + originPathUrl + } else { + thumbPathUrl + } + } + } + finalLoadUrl = finalLoadUrl.trim() + val url: String = finalLoadUrl + + // 鏄剧ず鍔犺浇鍦堝湀 + progressBar.visibility = View.VISIBLE + + // 鍒ゆ柇鍘熷浘缂撳瓨鏄惁瀛樺湪锛屽瓨鍦ㄧ殑璇濓紝鐩存帴鏄剧ず鍘熷浘缂撳瓨锛屼紭鍏堜繚璇佹竻鏅般�� + val cacheFile = getGlideCacheFile(imagePreviewActivity, originPathUrl) + if (cacheFile != null && cacheFile.exists()) { + SLog.d(TAG, "initImageType: 鍘熷浘缂撳瓨瀛樺湪锛岀洿鎺ユ樉绀� originPathUrl = $originPathUrl") + loadSuccess( + originPathUrl, + cacheFile + ) + } else { + SLog.d(TAG, "initImageType: 鍘熷浘缂撳瓨涓嶅瓨鍦紝寮�濮嬪姞杞� url = $url") + // 鍒ゆ柇url鏄惁鏄痳es璧勬簮 R.mipmap.xxx + if (url.startsWith("res://")) { + SLog.d(TAG, "initImageType: res璧勬簮") + loadResImage(url, originPathUrl) + } else { + SLog.d(TAG, "initImageType: url璧勬簮") + loadUrlImage( + url, + originPathUrl + ) + } + } + } + + @UnstableApi + private fun initVideoType() { + // 瑙嗛绫诲瀷锛岄殣钘忓浘鐗� + imageStatic.visibility = View.GONE + imageAnim.visibility = View.GONE + videoView.visibility = View.VISIBLE + progressBar.visibility = View.GONE + + // 鑷畾涔夋帶鍒� + refreshUIMargin() + val listener = object : Player.Listener { + override fun onVideoSizeChanged(videoSize: VideoSize) { + super.onVideoSizeChanged(videoSize) + SLog.d( + TAG, + "onVideoSizeChanged: videoSize = ${videoSize.width} * ${videoSize.height}" + ) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + SLog.d(TAG, "onIsPlayingChanged: isPlaying = $isPlaying") + if (isPlaying) { + ivPlayButton.setImageResource(R.drawable.icon_video_stop) + } else { + ivPlayButton.setImageResource(R.drawable.icon_video_play) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + SLog.d(TAG, "onPlaybackStateChanged: playbackState = $playbackState") + if (playbackState == Player.STATE_READY) { + // 搴曢儴鎺у埗鍣ㄥ鐞� + setProgress(exoPlayer!!) + videoView.hideController() + } else if (playbackState == Player.STATE_ENDED) { + // 鎾斁缁撴潫 + exoPlayer?.pause() + exoPlayer?.seekTo(0) + } + if (playbackState == Player.STATE_BUFFERING) { + // 缂撳啿涓� + progressBar.visibility = View.VISIBLE + } else { + progressBar.visibility = View.GONE + } + } + + override fun onPlayerError(error: PlaybackException) { + SLog.e("onPlayerError: $error") + if (isFirstPlay) { + isFirstPlay = false + // 閲婃斁褰撳墠鎾斁鍣� + exoPlayer?.release() + // 鍒涘缓鏂扮殑鎾斁鍣ㄥ疄渚� + exoPlayer = imagePreviewActivity.getNoOMXExoPlayer() + // 娣诲姞鐩戝惉鍣� + exoPlayer?.addListener(this) + // 璁剧疆濯掍綋椤� + val mediaItem = MediaItem.fromUri(imageInfo.originUrl) + exoPlayer?.setMediaItem(mediaItem) + // 鍑嗗鎾斁鍣� + exoPlayer?.prepare() + // 灏嗘柊鐨勬挱鏀惧櫒瀹炰緥缁戝畾鍒� videoView + videoView.player = exoPlayer + // 鎭㈠鎾斁 + exoPlayer?.play() + // 鏇存柊 UI 鐘舵�� + progressBar.visibility = View.GONE + ivPlayButton.setImageResource(R.drawable.icon_video_play) + } + + } + } + // 鍒濆鍖栨挱鏀惧櫒 + if (exoPlayer == null) { + exoPlayer = imagePreviewActivity.getExoPlayer() + exoPlayer?.addListener(listener) + + } + videoView.player = exoPlayer + + val mediaItem = MediaItem.fromUri(imageInfo.originUrl) + exoPlayer?.setMediaItem(mediaItem) + exoPlayer?.prepare() + exoPlayer?.playWhenReady = false + + if (ImagePreview.instance.index == position) { + // 濡傛灉鏄綋鍓嶉�変腑鐨勶紝灏辨挱鏀� + exoPlayer?.play() + isFirstPlay=true + } + } + + private fun setProgress(exoPlayer: ExoPlayer) { + // 娓呴櫎涔嬪墠鐨勪换鍔� + progressHandler?.removeCallbacksAndMessages(null) + progressHandler = Handler(Looper.getMainLooper()) + + // 瀹氫箟浠诲姟 + progressRunnable = object : Runnable { + override fun run() { + if (!isDragging) { + val currentPosition = exoPlayer.currentPosition + val currentTime = formatTimestamp(currentPosition / 1000) + val totalDuration = exoPlayer.duration + val totalTime = formatTimestamp(totalDuration / 1000) + + seekBar.max = totalDuration.toInt() + seekBar.progress = currentPosition.toInt() + + tvPlayTime.text = "$currentTime/$totalTime" + } + // 姣忕鏇存柊涓�娆� + progressHandler?.postDelayed(this, 1000) + } + } + + // 寮�濮嬩换鍔� + progressHandler?.post(progressRunnable!!) + + // 璁剧疆 SeekBar 鐨勭洃鍚櫒 + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + // 濡傛灉鏄敤鎴锋嫋鍔ㄧ殑锛屽垯鏇存柊鎾斁浣嶇疆銆� + exoPlayer.seekTo(progress.toLong()) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + isDragging = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + isDragging = false + } + }) + } + + private fun formatTimestamp(timestampInSeconds: Long): String { + val minutes = timestampInSeconds / 60 + val seconds = timestampInSeconds % 60 + return String.format(locale = Locale.getDefault(), "%02d:%02d", minutes, seconds) + } + + fun onOriginal() { + if (imageInfo.type == Type.IMAGE) { + SLog.d(TAG, "onOriginal: 鍔犺浇鍘熷浘") + loadOriginal() + } else { + SLog.d(TAG, "onOriginal: 瑙嗛绫诲瀷锛屼笉鍋氬鐞�") + } + } + + private fun loadOriginal() { + val originalUrl = imageInfo.originUrl + val cacheFile = getGlideCacheFile(imagePreviewActivity, imageInfo.originUrl) + if (cacheFile != null && cacheFile.exists()) { + val isStatic = ImageUtil.isStaticImage(originalUrl, cacheFile.absolutePath) + if (isStatic) { + SLog.d(TAG, "loadOriginal: 闈欐�佸浘") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_4444) + } else { + SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888) + } + imageAnim.visibility = View.GONE + imageStatic.visibility = View.VISIBLE + imageStatic.let { + val thumbnailUrl = imageInfo.thumbnailUrl + val smallCacheFile = getGlideCacheFile(imagePreviewActivity, thumbnailUrl) + var small: ImageSource? = null + if (smallCacheFile != null && smallCacheFile.exists()) { + val smallImagePath = smallCacheFile.absolutePath + small = ImageUtil.getImageBitmap( + smallImagePath, + ImageUtil.getBitmapDegree(smallImagePath) + )?.let { + ImageSource.bitmap(it) + } + val widSmall = ImageUtil.getWidthHeight(smallImagePath)[0] + val heiSmall = ImageUtil.getWidthHeight(smallImagePath)[1] + if (ImageUtil.isBmpImageWithMime(originalUrl, cacheFile.absolutePath)) { + small?.tilingDisabled() + } + small?.dimensions(widSmall, heiSmall) + } + val imagePath = cacheFile.absolutePath + val origin = ImageSource.uri(imagePath) + val widOrigin = ImageUtil.getWidthHeight(imagePath)[0] + val heiOrigin = ImageUtil.getWidthHeight(imagePath)[1] + if (ImageUtil.isBmpImageWithMime(originalUrl, cacheFile.absolutePath)) { + origin.tilingDisabled() + } + origin.dimensions(widOrigin, heiOrigin) + imageStatic.setImage(origin, small) + // 缂╂斁閫傞厤 + setImageStatic(imagePath, imageStatic) + } + } else { + SLog.d(TAG, "loadOriginal: 鍔ㄦ�佸浘") + imageStatic.visibility = View.GONE + imageAnim.visibility = View.VISIBLE + imageAnim.let { + Glide.with(imagePreviewActivity) + .asGif() + .load(cacheFile) + .apply( + RequestOptions() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .error(ImagePreview.instance.errorPlaceHolder) + ) + .into(imageAnim) + } + } + } + } + + fun onSelected() { + if (imageInfo.type == Type.VIDEO) { + exoPlayer?.seekTo(0) + exoPlayer?.play() + isFirstPlay=true; + } + } + + fun onUnSelected() { + if (imageInfo.type == Type.VIDEO) { + exoPlayer?.isPlaying?.let { + if (it) { + exoPlayer?.pause() + } + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + refreshUIMargin() + } + + private fun refreshUIMargin() { + val llControllerContainer = videoView.findViewById<LinearLayout>(R.id.llControllerContainer) + val layoutParams = llControllerContainer.layoutParams as MarginLayoutParams + // 鑾峰彇褰撳墠灞忓箷鏂瑰悜 + val orientation = resources.configuration.orientation + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + // 妯睆 + layoutParams.setMargins( + 0, + 0, + 0, + UIUtil.dp2px(imagePreviewActivity, 70f) + ) + } else { + // 绔栧睆 + layoutParams.setMargins( + 0, + 0, + 0, + UIUtil.dp2px(imagePreviewActivity, 70f) + PhoneUtil.getNavBarHeight( + imagePreviewActivity + ) + ) + } + llControllerContainer.layoutParams = layoutParams + } + + override fun onResume() { + super.onResume() + if (!mLoading) { + mLoading = true + // 鍒濆鍖栨椂璋冪敤涓�娆� + initData() + } else { + // 宸茬粡鍒濆鍖栬繃锛屽鏋滃綋鍓嶆槸瑙嗛锛屽氨鎵ц鎾斁 + if (imageInfo.type == Type.VIDEO) { + // 鍚庡彴鍓嶆槸鎾斁鐨勬墠鎭㈠鎾斁 + if (onPausePlaying == true) { + exoPlayer?.play() + } + } + } + } + + override fun onPause() { + super.onPause() + SLog.d(TAG, "onPause: position = $position") + if (imageInfo.type == Type.VIDEO) { + // 鍙壒娈婂鐞嗚棰戠被鍨� + onPausePlaying = exoPlayer?.isPlaying == true + onUnSelected() + } + } + + override fun onDestroyView() { + super.onDestroyView() + mLoading = false + onRelease() + } + + private fun loadResImage( + url: String, + originPathUrl: String + ) { + Glide.with(imagePreviewActivity).load(R.drawable.icon_download_new) + .addListener(object : RequestListener<Drawable> { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target<Drawable?>, + isFirstResource: Boolean + ): Boolean { + loadFailed(e) + return true + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target<Drawable>, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + progressBar.visibility = View.GONE + imageAnim.visibility = View.GONE + imageStatic.visibility = View.VISIBLE + imageStatic.setImage(ImageSource.resource(R.drawable.icon_download_new)) + return true + } + }).into(imageAnim) + } + + private fun loadUrlImage( + url: String, + originPathUrl: String + ) { + Glide.with(imagePreviewActivity).downloadOnly().load(url) + .addListener(object : RequestListener<File> { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target<File?>, + isFirstResource: Boolean + ): Boolean { + // glide鍔犺浇澶辫触锛屼娇鐢╤ttp涓嬭浇鍚庡啀娆″姞杞� + Thread { + val fileFullName = System.currentTimeMillis().toString() + val saveDir = + getAvailableCacheDir(imagePreviewActivity)?.absolutePath + File.separator + "image/" + val downloadFile = downloadFile(url, fileFullName, saveDir) + Handler(Looper.getMainLooper()).post { + if (downloadFile != null && downloadFile.exists() && downloadFile.length() > 0) { + // 閫氳繃urlConn涓嬭浇瀹屾垚 + loadSuccess( + originPathUrl, + downloadFile + ) + } else { + loadFailed(e) + } + } + }.start() + return true + } + + override fun onResourceReady( + resource: File, + model: Any, + target: Target<File>, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + loadSuccess( + url, + resource + ) + return true + } + }).into(object : FileTarget() { + }) + } + + private fun loadSuccess( + imageUrl: String, + resource: File + ) { + val imagePath = resource.absolutePath + val isStatic = ImageUtil.isStaticImage(imageUrl, imagePath) + if (isStatic) { + SLog.d(TAG, "loadSuccess: 鍔ㄩ潤鍒ゆ柇: 闈欐�佸浘") + loadImageStatic(imagePath) + } else { + SLog.d(TAG, "loadSuccess: 鍔ㄩ潤鍒ゆ柇: 鍔ㄦ�佸浘") + loadImageAnim( + imageUrl, + imagePath + ) + } + } + + /** + * 鍔犺浇澶辫触鐨勫鐞� + */ + private fun loadFailed( + e: GlideException? + ) { + progressBar.visibility = View.GONE + imageAnim.visibility = View.GONE + imageStatic.visibility = View.VISIBLE + imageStatic.isZoomEnabled = false + imageStatic.setImage(ImageSource.resource(ImagePreview.instance.errorPlaceHolder)) + if (ImagePreview.instance.isShowErrorToast) { + var errorMsg = imagePreviewActivity.getString(R.string.toast_load_failed) + if (e != null) { + errorMsg = e.localizedMessage as String + } + if (errorMsg.length > 200) { + errorMsg = errorMsg.substring(0, 199) + } + ToastUtil.instance.showShort(imagePreviewActivity.applicationContext, errorMsg) + } + } + + private fun setImageStatic(imagePath: String, imageStatic: SubsamplingScaleImageView) { + imageStatic.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_4444) + } else { + SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888) + } + val tabletOrLandscape = ImageUtil.isTabletOrLandscape(imagePreviewActivity) + if (tabletOrLandscape) { + // Tablet + imageStatic.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) + imageStatic.minScale = ImagePreview.instance.minScale + imageStatic.maxScale = ImagePreview.instance.maxScale + imageStatic.setDoubleTapZoomScale(ImagePreview.instance.mediumScale) + } else { + // Phone + imageStatic.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) + imageStatic.minScale = 1f + val isLongImage = ImageUtil.isLongImage(imagePath) + val isWideImage = ImageUtil.isWideImage(imagePath) + if (isLongImage) { + // 闀垮浘锛岄珮/瀹�>=3 + imageStatic.maxScale = + ImageUtil.getLongImageMaxZoomScale(imagePreviewActivity, imagePath) + imageStatic.setDoubleTapZoomScale( + ImageUtil.getLongImageDoubleZoomScale( + imagePreviewActivity, + imagePath + ) + ) + // 璁剧疆闀垮浘鐨勯粯璁ゅ睍绀烘ā寮忥細瀹藉害鎷夋弧/灞呬腑鏄剧ず + when (ImagePreview.instance.longPicDisplayMode) { + ImagePreview.LongPicDisplayMode.Default -> { + } + + ImagePreview.LongPicDisplayMode.FillWidth -> { + imageStatic.setScaleAndCenter( + ImageUtil.getLongImageFillWidthScale( + this.imagePreviewActivity, + imagePath + ), PointF(0f, 0f) + ) + } + } + } else if (isWideImage) { + // 瀹藉浘锛屽/楂�>=3 + imageStatic.maxScale = + ImageUtil.getWideImageMaxZoomScale(imagePreviewActivity, imagePath) + imageStatic.setDoubleTapZoomScale( + ImageUtil.getWideImageDoubleScale( + imagePreviewActivity, + imagePath + ) + ) + } else { + // 鏅�氬浘鐗囷紝鍏朵粬 + imageStatic.maxScale = + ImageUtil.getStandardImageMaxZoomScale(imagePreviewActivity, imagePath) + imageStatic.setDoubleTapZoomScale( + ImageUtil.getStandardImageDoubleScale( + imagePreviewActivity, + imagePath + ) + ) + } + } + } + + /** + * 鍔犺浇闈欐�佸浘鐗� + */ + private fun loadImageStatic( + imagePath: String + ) { + imageAnim.visibility = View.GONE + imageStatic.visibility = View.VISIBLE + val imageSource = ImageSource.uri(Uri.fromFile(File(imagePath))) + if (ImageUtil.isBmpImageWithMime(imagePath, imagePath)) { + imageSource.tilingDisabled() + } + imageStatic.setImage(imageSource) + imageStatic.setOnImageEventListener(object : SimpleOnImageEventListener() { + override fun onReady() { + progressBar.visibility = View.GONE + } + }) + // 缂╂斁閫傞厤 + setImageStatic(imagePath, imageStatic) + } + + /** + * 鍔犺浇鍔ㄥ浘 + */ + private fun loadImageAnim( + imageUrl: String, imagePath: String + ) { + imageStatic.visibility = View.GONE + imageAnim.visibility = View.VISIBLE + if (ImageUtil.isAnimWebp(imageUrl, imagePath)) { + val fitCenter: Transformation<Bitmap> = FitCenter() + Glide.with(imagePreviewActivity) + .load(imagePath) + .apply( + RequestOptions().diskCacheStrategy(DiskCacheStrategy.ALL) + .error(ImagePreview.instance.errorPlaceHolder) + ) + .optionalTransform(fitCenter) + .optionalTransform(WebpDrawable::class.java, WebpDrawableTransformation(fitCenter)) + .addListener(object : RequestListener<Drawable> { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target<Drawable?>, + isFirstResource: Boolean + ): Boolean { + progressBar.visibility = View.GONE + return false + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target<Drawable?>?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + progressBar.visibility = View.GONE + return false + } + }) + .into(imageAnim) + } else { + Glide.with(imagePreviewActivity) + .asGif() + .load(imagePath) + .apply( + RequestOptions().diskCacheStrategy(DiskCacheStrategy.ALL) + .error(ImagePreview.instance.errorPlaceHolder) + ) + .listener(object : RequestListener<GifDrawable?> { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target<GifDrawable?>, + isFirstResource: Boolean + ): Boolean { + progressBar.visibility = View.GONE + imageStatic.setImage(ImageSource.resource(ImagePreview.instance.errorPlaceHolder)) + return false + } + + override fun onResourceReady( + resource: GifDrawable, + model: Any, + target: Target<GifDrawable?>?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + progressBar.visibility = View.GONE + return false + } + }) + .into(imageAnim) + } + } + + fun onRelease() { + if (::imageStatic.isInitialized) { + imageStatic.destroyDrawingCache() + imageStatic.recycle() + } + if (::imageAnim.isInitialized) { + imageAnim.destroyDrawingCache() + imageAnim.setImageBitmap(null) + } + exoPlayer?.release() + exoPlayer = null + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/helper/DragCloseView.java b/library/src/main/java/cc/shinichi/library/view/helper/DragCloseView.java new file mode 100644 index 0000000..a8b61a4 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/helper/DragCloseView.java @@ -0,0 +1,255 @@ +package cc.shinichi.library.view.helper; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.LinearInterpolator; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import cc.shinichi.library.ImagePreview; +import cc.shinichi.library.view.nine.ViewHelper; +import cc.shinichi.library.view.photoview.PhotoView; +import cc.shinichi.library.view.subsampling.SubsamplingScaleImageView; + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * create at 2018/10/19 11:22 + * description:杈呭姪涓嬫媺鍏抽棴鍥剧墖 + */ +public class DragCloseView extends RelativeLayout { + + private static final String TAG = DragCloseView.class.getSimpleName(); + + private final static int MAX_EXIT_Y = 500; + private final static long DURATION = 200; + + private SubsamplingScaleImageView imageStatic; + private PhotoView imageAnime; + private View videoView; + + private float mDownX; + private float mDownY; + private float mTranslationY; + + private onAlphaChangedListener mOnAlphaChangedListener; + + public DragCloseView(Context context) { + this(context, null); + } + + public DragCloseView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public DragCloseView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + imageStatic = (SubsamplingScaleImageView) getChildAt(0); + imageAnime = (PhotoView) getChildAt(1); + videoView = getChildAt(2); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (ImagePreview.Companion.getInstance().isEnableDragClose()) { + int action = ev.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + mDownX = ev.getRawX(); + mDownY = ev.getRawY(); + } else if (action == MotionEvent.ACTION_MOVE) { + return canInterceptDrag(ev); + } + } + return false; + } + + private boolean canInterceptDrag(MotionEvent ev) { + float diffX = Math.abs(ev.getX() - mDownX); + float diffY = Math.abs(ev.getY() - mDownY); + if (diffY <= diffX) { + // 涓嬫媺鎵嬪娍锛孻 杞翠綅绉诲皬浜� X 杞翠綅绉伙紝鏄í鍚戞粦鍔紝涓嶆嫤鎴� + return false; + } + getParent().requestDisallowInterceptTouchEvent(true); + if (imageStatic != null && imageStatic.getVisibility() == View.VISIBLE) { + // 闈欏浘 + boolean isAtEdge = ImagePreview.Companion.getInstance().isEnableDragCloseIgnoreScale() + ? imageStatic.getScale() <= (imageStatic.getMinScale() + 0.001F) || imageStatic.isAtYEdge() + : imageStatic.getScale() <= (imageStatic.getMinScale() + 0.001F) && imageStatic.isAtYEdge(); + return isAtEdge && (imageStatic.getMaxTouchCount() == 0 || imageStatic.getMaxTouchCount() == 1); + } else if (imageAnime != null && imageAnime.getVisibility() == View.VISIBLE) { + // 鍔ㄥ浘 + return imageAnime.getScale() <= (imageAnime.getMinimumScale() + 0.001F) && (imageAnime.getMaxTouchCount() == 0 || imageAnime.getMaxTouchCount() == 1); + } else if (videoView != null && videoView.getVisibility() == View.VISIBLE) { + // 瑙嗛 + return true; + } + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent event) { + int action = event.getAction() & event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + mDownX = event.getRawX(); + mDownY = event.getRawY(); + case MotionEvent.ACTION_MOVE: + if (ImagePreview.Companion.getInstance().isEnableDragClose()) { + if ((imageAnime != null && imageAnime.getVisibility() == View.VISIBLE) + || (imageStatic != null && imageStatic.getVisibility() == View.VISIBLE) + || (videoView != null && videoView.getVisibility() == View.VISIBLE) + ) { + onOneFingerPanActionMove(event); + } + } + break; + case MotionEvent.ACTION_UP: + onActionUp(); + break; + default: + break; + } + return true; + } + + /** + * 澶勭悊鎷栨嫿浜嬩欢 + * + * @param event + */ + private void onOneFingerPanActionMove(MotionEvent event) { + float moveY = event.getRawY(); + mTranslationY = moveY - mDownY; + if (mOnAlphaChangedListener != null) { + mOnAlphaChangedListener.onTranslationYChanged(event, mTranslationY); + } + setTranslationY(mTranslationY); + } + + private void onActionUp() { + // 鏄惁鍚敤涓婃媺鍏抽棴 + boolean enableUpDragClose = ImagePreview.Companion.getInstance().isEnableUpDragClose(); + if (enableUpDragClose) { + if (Math.abs(mTranslationY) > MAX_EXIT_Y) { + if (null != mOnAlphaChangedListener) { + mOnAlphaChangedListener.onExit(); + } + exit(); + } else { + resetCallBackAnimation(); + } + } else { + if (mTranslationY > MAX_EXIT_Y) { + if (null != mOnAlphaChangedListener) { + mOnAlphaChangedListener.onExit(); + } + exit(); + } else { + resetCallBackAnimation(); + } + } + } + + private void resetCallBackAnimation() { + ValueAnimator animatorY = ValueAnimator.ofFloat(mTranslationY, 0); + animatorY.setDuration(DURATION); + animatorY.addUpdateListener(animation -> { + mTranslationY = (float) animation.getAnimatedValue(); + setTranslationY(mTranslationY); + }); + animatorY.addListener(new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(@NonNull Animator animation) { + mTranslationY = 0; + reset(); + } + }); + animatorY.start(); + } + + private void exit() { + ValueAnimator animatorExit; + if (mTranslationY > 0) { + animatorExit = ValueAnimator.ofFloat(mTranslationY, getHeight()); + } else { + animatorExit = ValueAnimator.ofFloat(mTranslationY, -getHeight()); + } + animatorExit.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(@NonNull ValueAnimator animation) { + float fraction = (float) animation.getAnimatedValue(); + ViewHelper.setScrollY(DragCloseView.this, -(int) fraction); + } + }); + animatorExit.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(@NonNull Animator animation) { + } + + @Override + public void onAnimationEnd(@NonNull Animator animation) { + ((Activity) getContext()).finish(); + } + + @Override + public void onAnimationCancel(@NonNull Animator animation) { + } + + @Override + public void onAnimationRepeat(@NonNull Animator animation) { + } + }); + animatorExit.setDuration(DURATION); + animatorExit.setInterpolator(new LinearInterpolator()); + animatorExit.start(); + } + + /** + * 鏆撮湶鐨勫洖璋冩柟娉曪紙鍙牴鎹綅绉昏窛绂绘垨鑰卆lpha鏉ユ敼鍙樹富UI鎺т欢鐨勯�忔槑搴︾瓑 + */ + public void setOnAlphaChangeListener(onAlphaChangedListener alphaChangeListener) { + mOnAlphaChangedListener = alphaChangeListener; + } + + private void reset() { + if (null != mOnAlphaChangedListener) { + mOnAlphaChangedListener.onTranslationYChanged(null, mTranslationY); + } + } + + public abstract static class SimpleAnimatorListener implements Animator.AnimatorListener { + @Override + public void onAnimationStart(@NonNull Animator animation) { + } + + @Override + public void onAnimationCancel(@NonNull Animator animation) { + } + + @Override + public void onAnimationRepeat(@NonNull Animator animation) { + } + } + + public interface onAlphaChangedListener { + + void onTranslationYChanged(MotionEvent event, float translationY); + + void onExit(); + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnBigImageClickListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnBigImageClickListener.kt new file mode 100644 index 0000000..d91aad7 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnBigImageClickListener.kt @@ -0,0 +1,18 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity +import android.view.View + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2018/12/19 16:23 + * description: + */ +interface OnBigImageClickListener { + /** + * 鐐瑰嚮浜嬩欢 + */ + fun onClick(activity: Activity, view: View, position: Int) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnBigImageLongClickListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnBigImageLongClickListener.kt new file mode 100644 index 0000000..ed9c239 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnBigImageLongClickListener.kt @@ -0,0 +1,18 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity +import android.view.View + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2018/12/19 16:24 + * description: + */ +interface OnBigImageLongClickListener { + /** + * 闀挎寜浜嬩欢 + */ + fun onLongClick(activity: Activity, view: View, position: Int): Boolean +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnBigImagePageChangeListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnBigImagePageChangeListener.kt new file mode 100644 index 0000000..c935850 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnBigImagePageChangeListener.kt @@ -0,0 +1,43 @@ +package cc.shinichi.library.view.listener + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2019/1/7 11:45 + * description: + */ +interface OnBigImagePageChangeListener { + /** + * This method will be invoked when the current page is scrolled, either as part + * of a programmatically initiated smooth scroll or a user initiated touch scroll. + * + * @param position Position index of the first page currently being displayed. + * Page position+1 will be visible if positionOffset is nonzero. + * @param positionOffset Value from [0, 1) indicating the offset from the page at position. + * @param positionOffsetPixels Value in pixels indicating the offset from position. + */ + fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) + + /** + * This method will be invoked when a new page becomes selected. Animation is not + * necessarily complete. + * + * @param position Position index of the new selected page. + */ + fun onPageSelected(position: Int) + + /** + * Called when the scroll state changes. Useful for discovering when the user + * begins dragging, when the pager is automatically settling to the current page, + * or when it is fully stopped/idle. + * + * @param state The new scroll state. + * @see ViewPager.SCROLL_STATE_IDLE + * + * @see ViewPager.SCROLL_STATE_DRAGGING + * + * @see ViewPager.SCROLL_STATE_SETTLING + */ + fun onPageScrollStateChanged(state: Int) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnCustomLayoutCallback.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnCustomLayoutCallback.kt new file mode 100644 index 0000000..163a973 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnCustomLayoutCallback.kt @@ -0,0 +1,9 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity +import android.view.View + +interface OnCustomLayoutCallback { + + fun onLayout(activity: Activity, parentView: View) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnDownloadClickListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnDownloadClickListener.kt new file mode 100644 index 0000000..175f5ba --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnDownloadClickListener.kt @@ -0,0 +1,26 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity +import android.view.View + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2018/12/19 16:23 + * description: + */ +abstract class OnDownloadClickListener { + /** + * 鐐瑰嚮浜嬩欢 + * 鏄惁鎷︽埅涓嬭浇琛屼负 + */ + abstract fun onClick(activity: Activity, view: View, position: Int) + + /** + * 鏄惁鎷︽埅涓嬭浇 + * + * @return + */ + abstract val isInterceptDownload: Boolean +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnDownloadListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnDownloadListener.kt new file mode 100644 index 0000000..8c09df0 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnDownloadListener.kt @@ -0,0 +1,16 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2018/12/19 16:23 + * description: 涓嬭浇缁撴灉鍥炶皟 + */ +abstract class OnDownloadListener { + abstract fun onDownloadStart(activity: Activity, position: Int) + abstract fun onDownloadSuccess(activity: Activity, position: Int) + abstract fun onDownloadFailed(activity: Activity, position: Int) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnFinishListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnFinishListener.kt new file mode 100644 index 0000000..3f7f438 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnFinishListener.kt @@ -0,0 +1,6 @@ +package cc.shinichi.library.view.listener + +interface OnFinishListener { + + fun onFinish() +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnOriginProgressListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnOriginProgressListener.kt new file mode 100644 index 0000000..c7fc3cd --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnOriginProgressListener.kt @@ -0,0 +1,22 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity +import android.view.View + +/** + * 鍘熷浘鍔犺浇鐧惧垎姣旀帴鍙� + * + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + */ +interface OnOriginProgressListener { + /** + * 鍔犺浇涓� + */ + fun progress(activity: Activity, parentView: View, progress: Int) + + /** + * 鍔犺浇瀹屾垚 + */ + fun finish(activity: Activity, parentView: View) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnPageDragListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnPageDragListener.kt new file mode 100644 index 0000000..7fc59d5 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnPageDragListener.kt @@ -0,0 +1,17 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity +import android.view.MotionEvent +import android.view.View + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2018/12/19 16:23 + * description: 椤甸潰鎷栨嫿鍥炶皟 + */ +abstract class OnPageDragListener { + abstract fun onDrag(activity: Activity, parentView: View, event: MotionEvent?, translationY: Float) + abstract fun onDragEnd(activity: Activity, parentView: View) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/OnPageFinishListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/OnPageFinishListener.kt new file mode 100644 index 0000000..15654aa --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/OnPageFinishListener.kt @@ -0,0 +1,14 @@ +package cc.shinichi.library.view.listener + +import android.app.Activity + +/** + * @author 宸ヨ棨 + * @email qinglingou@gmail.com + * cc.shinichi.library.view.listener + * create at 2018/12/19 16:23 + * description: 椤甸潰鍏抽棴鍥炶皟 + */ +abstract class OnPageFinishListener { + abstract fun onFinish(activity: Activity) +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/listener/SimpleOnImageEventListener.kt b/library/src/main/java/cc/shinichi/library/view/listener/SimpleOnImageEventListener.kt new file mode 100644 index 0000000..f37a594 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/listener/SimpleOnImageEventListener.kt @@ -0,0 +1,18 @@ +package cc.shinichi.library.view.listener + +import cc.shinichi.library.view.subsampling.SubsamplingScaleImageView + +open class SimpleOnImageEventListener : SubsamplingScaleImageView.OnImageEventListener { + + override fun onReady() {} + + override fun onImageLoaded() {} + + override fun onPreviewLoadError(e: Exception) {} + + override fun onImageLoadError(e: Exception) {} + + override fun onTileLoadError(e: Exception) {} + + override fun onPreviewReleased() {} +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/nine/AnimatorProxy.java b/library/src/main/java/cc/shinichi/library/view/nine/AnimatorProxy.java new file mode 100644 index 0000000..0b25040 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/nine/AnimatorProxy.java @@ -0,0 +1,345 @@ +package cc.shinichi.library.view.nine; + +import android.graphics.Camera; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.os.Build; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +import java.lang.ref.WeakReference; +import java.util.WeakHashMap; + +/** + * A proxy class to allow for modifying post-3.0 view properties on all pre-3.0 + * platforms. <strong>DO NOT</strong> wrap your views with this class if you + * are using {@code ObjectAnimator} as it will handle that itself. + */ +public final class AnimatorProxy extends Animation { + /** + * Whether or not the current running platform needs to be proxied. + */ + @SuppressWarnings("deprecation") + public static final boolean NEEDS_PROXY = + Integer.valueOf(Build.VERSION.SDK).intValue() < Build.VERSION_CODES.HONEYCOMB; + + private static final WeakHashMap<View, AnimatorProxy> PROXIES = new WeakHashMap<View, AnimatorProxy>(); + private final WeakReference<View> mView; + private final Camera mCamera = new Camera(); + private final RectF mBefore = new RectF(); + private final RectF mAfter = new RectF(); + private final Matrix mTempMatrix = new Matrix(); + private boolean mHasPivot; + private float mAlpha = 1; + private float mPivotX; + private float mPivotY; + private float mRotationX; + private float mRotationY; + private float mRotationZ; + private float mScaleX = 1; + private float mScaleY = 1; + private float mTranslationX; + private float mTranslationY; + + private AnimatorProxy(View view) { + setDuration(0); //perform transformation immediately + setFillAfter(true); //persist transformation beyond duration + view.setAnimation(this); + mView = new WeakReference<View>(view); + } + + /** + * Create a proxy to allow for modifying post-3.0 view properties on all + * pre-3.0 platforms. <strong>DO NOT</strong> wrap your views if you are + * using {@code ObjectAnimator} as it will handle that itself. + * + * @param view View to wrap. + * @return Proxy to post-3.0 properties. + */ + public static AnimatorProxy wrap(View view) { + AnimatorProxy proxy = PROXIES.get(view); + // This checks if the proxy already exists and whether it still is the animation of the given view + if (proxy == null || proxy != view.getAnimation()) { + proxy = new AnimatorProxy(view); + PROXIES.put(view, proxy); + } + return proxy; + } + + public float getAlpha() { + return mAlpha; + } + + public void setAlpha(float alpha) { + if (mAlpha != alpha) { + mAlpha = alpha; + View view = mView.get(); + if (view != null) { + view.invalidate(); + } + } + } + + public float getPivotX() { + return mPivotX; + } + + public void setPivotX(float pivotX) { + if (!mHasPivot || mPivotX != pivotX) { + prepareForUpdate(); + mHasPivot = true; + mPivotX = pivotX; + invalidateAfterUpdate(); + } + } + + public float getPivotY() { + return mPivotY; + } + + public void setPivotY(float pivotY) { + if (!mHasPivot || mPivotY != pivotY) { + prepareForUpdate(); + mHasPivot = true; + mPivotY = pivotY; + invalidateAfterUpdate(); + } + } + + public float getRotation() { + return mRotationZ; + } + + public void setRotation(float rotation) { + if (mRotationZ != rotation) { + prepareForUpdate(); + mRotationZ = rotation; + invalidateAfterUpdate(); + } + } + + public float getRotationX() { + return mRotationX; + } + + public void setRotationX(float rotationX) { + if (mRotationX != rotationX) { + prepareForUpdate(); + mRotationX = rotationX; + invalidateAfterUpdate(); + } + } + + public float getRotationY() { + return mRotationY; + } + + public void setRotationY(float rotationY) { + if (mRotationY != rotationY) { + prepareForUpdate(); + mRotationY = rotationY; + invalidateAfterUpdate(); + } + } + + public float getScaleX() { + return mScaleX; + } + + public void setScaleX(float scaleX) { + if (mScaleX != scaleX) { + prepareForUpdate(); + mScaleX = scaleX; + invalidateAfterUpdate(); + } + } + + public float getScaleY() { + return mScaleY; + } + + public void setScaleY(float scaleY) { + if (mScaleY != scaleY) { + prepareForUpdate(); + mScaleY = scaleY; + invalidateAfterUpdate(); + } + } + + public int getScrollX() { + View view = mView.get(); + if (view == null) { + return 0; + } + return view.getScrollX(); + } + + public void setScrollX(int value) { + View view = mView.get(); + if (view != null) { + view.scrollTo(value, view.getScrollY()); + } + } + + public int getScrollY() { + View view = mView.get(); + if (view == null) { + return 0; + } + return view.getScrollY(); + } + + public void setScrollY(int value) { + View view = mView.get(); + if (view != null) { + view.scrollTo(view.getScrollX(), value); + } + } + + public float getTranslationX() { + return mTranslationX; + } + + public void setTranslationX(float translationX) { + if (mTranslationX != translationX) { + prepareForUpdate(); + mTranslationX = translationX; + invalidateAfterUpdate(); + } + } + + public float getTranslationY() { + return mTranslationY; + } + + public void setTranslationY(float translationY) { + if (mTranslationY != translationY) { + prepareForUpdate(); + mTranslationY = translationY; + invalidateAfterUpdate(); + } + } + + public float getX() { + View view = mView.get(); + if (view == null) { + return 0; + } + return view.getLeft() + mTranslationX; + } + + public void setX(float x) { + View view = mView.get(); + if (view != null) { + setTranslationX(x - view.getLeft()); + } + } + + public float getY() { + View view = mView.get(); + if (view == null) { + return 0; + } + return view.getTop() + mTranslationY; + } + + public void setY(float y) { + View view = mView.get(); + if (view != null) { + setTranslationY(y - view.getTop()); + } + } + + private void prepareForUpdate() { + View view = mView.get(); + if (view != null) { + computeRect(mBefore, view); + } + } + + private void invalidateAfterUpdate() { + View view = mView.get(); + if (view == null || view.getParent() == null) { + return; + } + + final RectF after = mAfter; + computeRect(after, view); + after.union(mBefore); + + ((View) view.getParent()).invalidate((int) Math.floor(after.left), (int) Math.floor(after.top), + (int) Math.ceil(after.right), (int) Math.ceil(after.bottom)); + } + + private void computeRect(final RectF r, View view) { + // compute current rectangle according to matrix transformation + final float w = view.getWidth(); + final float h = view.getHeight(); + + // use a rectangle at 0,0 to make sure we don't run into issues with scaling + r.set(0, 0, w, h); + + final Matrix m = mTempMatrix; + m.reset(); + transformMatrix(m, view); + mTempMatrix.mapRect(r); + + r.offset(view.getLeft(), view.getTop()); + + // Straighten coords if rotations flipped them + if (r.right < r.left) { + final float f = r.right; + r.right = r.left; + r.left = f; + } + if (r.bottom < r.top) { + final float f = r.top; + r.top = r.bottom; + r.bottom = f; + } + } + + private void transformMatrix(Matrix m, View view) { + final float w = view.getWidth(); + final float h = view.getHeight(); + final boolean hasPivot = mHasPivot; + final float pX = hasPivot ? mPivotX : w / 2f; + final float pY = hasPivot ? mPivotY : h / 2f; + + final float rX = mRotationX; + final float rY = mRotationY; + final float rZ = mRotationZ; + if ((rX != 0) || (rY != 0) || (rZ != 0)) { + final Camera camera = mCamera; + camera.save(); + camera.rotateX(rX); + camera.rotateY(rY); + camera.rotateZ(-rZ); + camera.getMatrix(m); + camera.restore(); + m.preTranslate(-pX, -pY); + m.postTranslate(pX, pY); + } + + final float sX = mScaleX; + final float sY = mScaleY; + if ((sX != 1.0f) || (sY != 1.0f)) { + m.postScale(sX, sY); + final float sPX = -(pX / w) * ((sX * w) - w); + final float sPY = -(pY / h) * ((sY * h) - h); + m.postTranslate(sPX, sPY); + } + + m.postTranslate(mTranslationX, mTranslationY); + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + View view = mView.get(); + if (view != null) { + t.setAlpha(mAlpha); + transformMatrix(t.getMatrix(), view); + } + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/nine/ViewHelper.java b/library/src/main/java/cc/shinichi/library/view/nine/ViewHelper.java new file mode 100644 index 0000000..682925a --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/nine/ViewHelper.java @@ -0,0 +1,293 @@ +package cc.shinichi.library.view.nine; + +import static cc.shinichi.library.view.nine.AnimatorProxy.NEEDS_PROXY; +import static cc.shinichi.library.view.nine.AnimatorProxy.wrap; + +import android.view.View; + +public final class ViewHelper { + private ViewHelper() { + } + + public static float getAlpha(View view) { + return NEEDS_PROXY ? wrap(view).getAlpha() : Honeycomb.getAlpha(view); + } + + public static void setAlpha(View view, float alpha) { + if (NEEDS_PROXY) { + wrap(view).setAlpha(alpha); + } else { + Honeycomb.setAlpha(view, alpha); + } + } + + public static float getPivotX(View view) { + return NEEDS_PROXY ? wrap(view).getPivotX() : Honeycomb.getPivotX(view); + } + + public static void setPivotX(View view, float pivotX) { + if (NEEDS_PROXY) { + wrap(view).setPivotX(pivotX); + } else { + Honeycomb.setPivotX(view, pivotX); + } + } + + public static float getPivotY(View view) { + return NEEDS_PROXY ? wrap(view).getPivotY() : Honeycomb.getPivotY(view); + } + + public static void setPivotY(View view, float pivotY) { + if (NEEDS_PROXY) { + wrap(view).setPivotY(pivotY); + } else { + Honeycomb.setPivotY(view, pivotY); + } + } + + public static float getRotation(View view) { + return NEEDS_PROXY ? wrap(view).getRotation() : Honeycomb.getRotation(view); + } + + public static void setRotation(View view, float rotation) { + if (NEEDS_PROXY) { + wrap(view).setRotation(rotation); + } else { + Honeycomb.setRotation(view, rotation); + } + } + + public static float getRotationX(View view) { + return NEEDS_PROXY ? wrap(view).getRotationX() : Honeycomb.getRotationX(view); + } + + public static void setRotationX(View view, float rotationX) { + if (NEEDS_PROXY) { + wrap(view).setRotationX(rotationX); + } else { + Honeycomb.setRotationX(view, rotationX); + } + } + + public static float getRotationY(View view) { + return NEEDS_PROXY ? wrap(view).getRotationY() : Honeycomb.getRotationY(view); + } + + public static void setRotationY(View view, float rotationY) { + if (NEEDS_PROXY) { + wrap(view).setRotationY(rotationY); + } else { + Honeycomb.setRotationY(view, rotationY); + } + } + + public static float getScaleX(View view) { + return NEEDS_PROXY ? wrap(view).getScaleX() : Honeycomb.getScaleX(view); + } + + public static void setScaleX(View view, float scaleX) { + if (NEEDS_PROXY) { + wrap(view).setScaleX(scaleX); + } else { + Honeycomb.setScaleX(view, scaleX); + } + } + + public static float getScaleY(View view) { + return NEEDS_PROXY ? wrap(view).getScaleY() : Honeycomb.getScaleY(view); + } + + public static void setScaleY(View view, float scaleY) { + if (NEEDS_PROXY) { + wrap(view).setScaleY(scaleY); + } else { + Honeycomb.setScaleY(view, scaleY); + } + } + + public static float getScrollX(View view) { + return NEEDS_PROXY ? wrap(view).getScrollX() : Honeycomb.getScrollX(view); + } + + public static void setScrollX(View view, int scrollX) { + if (NEEDS_PROXY) { + wrap(view).setScrollX(scrollX); + } else { + Honeycomb.setScrollX(view, scrollX); + } + } + + public static float getScrollY(View view) { + return NEEDS_PROXY ? wrap(view).getScrollY() : Honeycomb.getScrollY(view); + } + + public static void setScrollY(View view, int scrollY) { + if (NEEDS_PROXY) { + wrap(view).setScrollY(scrollY); + } else { + Honeycomb.setScrollY(view, scrollY); + } + } + + public static float getTranslationX(View view) { + return NEEDS_PROXY ? wrap(view).getTranslationX() : Honeycomb.getTranslationX(view); + } + + public static void setTranslationX(View view, float translationX) { + if (NEEDS_PROXY) { + wrap(view).setTranslationX(translationX); + } else { + Honeycomb.setTranslationX(view, translationX); + } + } + + public static float getTranslationY(View view) { + return NEEDS_PROXY ? wrap(view).getTranslationY() : Honeycomb.getTranslationY(view); + } + + public static void setTranslationY(View view, float translationY) { + if (NEEDS_PROXY) { + wrap(view).setTranslationY(translationY); + } else { + Honeycomb.setTranslationY(view, translationY); + } + } + + public static float getX(View view) { + return NEEDS_PROXY ? wrap(view).getX() : Honeycomb.getX(view); + } + + public static void setX(View view, float x) { + if (NEEDS_PROXY) { + wrap(view).setX(x); + } else { + Honeycomb.setX(view, x); + } + } + + public static float getY(View view) { + return NEEDS_PROXY ? wrap(view).getY() : Honeycomb.getY(view); + } + + public static void setY(View view, float y) { + if (NEEDS_PROXY) { + wrap(view).setY(y); + } else { + Honeycomb.setY(view, y); + } + } + + private static final class Honeycomb { + static float getAlpha(View view) { + return view.getAlpha(); + } + + static void setAlpha(View view, float alpha) { + view.setAlpha(alpha); + } + + static float getPivotX(View view) { + return view.getPivotX(); + } + + static void setPivotX(View view, float pivotX) { + view.setPivotX(pivotX); + } + + static float getPivotY(View view) { + return view.getPivotY(); + } + + static void setPivotY(View view, float pivotY) { + view.setPivotY(pivotY); + } + + static float getRotation(View view) { + return view.getRotation(); + } + + static void setRotation(View view, float rotation) { + view.setRotation(rotation); + } + + static float getRotationX(View view) { + return view.getRotationX(); + } + + static void setRotationX(View view, float rotationX) { + view.setRotationX(rotationX); + } + + static float getRotationY(View view) { + return view.getRotationY(); + } + + static void setRotationY(View view, float rotationY) { + view.setRotationY(rotationY); + } + + static float getScaleX(View view) { + return view.getScaleX(); + } + + static void setScaleX(View view, float scaleX) { + view.setScaleX(scaleX); + } + + static float getScaleY(View view) { + return view.getScaleY(); + } + + static void setScaleY(View view, float scaleY) { + view.setScaleY(scaleY); + } + + static float getScrollX(View view) { + return view.getScrollX(); + } + + static void setScrollX(View view, int scrollX) { + view.setScrollX(scrollX); + } + + static float getScrollY(View view) { + return view.getScrollY(); + } + + static void setScrollY(View view, int scrollY) { + view.setScrollY(scrollY); + } + + static float getTranslationX(View view) { + return view.getTranslationX(); + } + + static void setTranslationX(View view, float translationX) { + view.setTranslationX(translationX); + } + + static float getTranslationY(View view) { + return view.getTranslationY(); + } + + static void setTranslationY(View view, float translationY) { + view.setTranslationY(translationY); + } + + static float getX(View view) { + return view.getX(); + } + + static void setX(View view, float x) { + view.setX(x); + } + + static float getY(View view) { + return view.getY(); + } + + static void setY(View view, float y) { + view.setY(y); + } + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/Compat.java b/library/src/main/java/cc/shinichi/library/view/photoview/Compat.java new file mode 100644 index 0000000..22ebc88 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/Compat.java @@ -0,0 +1,33 @@ +/* + Copyright 2011, 2012 Chris Banes. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package cc.shinichi.library.view.photoview; + +import android.annotation.TargetApi; +import android.view.View; + +class Compat { + + private static final int SIXTY_FPS_INTERVAL = 1000 / 60; + + public static void postOnAnimation(View view, Runnable runnable) { + postOnAnimationJellyBean(view, runnable); + } + + @TargetApi(16) + private static void postOnAnimationJellyBean(View view, Runnable runnable) { + view.postOnAnimation(runnable); + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/CustomGestureDetector.java b/library/src/main/java/cc/shinichi/library/view/photoview/CustomGestureDetector.java new file mode 100644 index 0000000..d08ed6e --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/CustomGestureDetector.java @@ -0,0 +1,198 @@ +/* + Copyright 2011, 2012 Chris Banes. + <p/> + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + <p/> + http://www.apache.org/licenses/LICENSE-2.0 + <p/> + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package cc.shinichi.library.view.photoview; + +import android.content.Context; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.VelocityTracker; +import android.view.ViewConfiguration; + +/** + * Does a whole lot of gesture detecting. + */ +class CustomGestureDetector { + + private static final int INVALID_POINTER_ID = -1; + private final ScaleGestureDetector mDetector; + private final float mTouchSlop; + private final float mMinimumVelocity; + private final OnGestureListener mListener; + private int mActivePointerId = INVALID_POINTER_ID; + private int mActivePointerIndex = 0; + private VelocityTracker mVelocityTracker; + private boolean mIsDragging; + private float mLastTouchX; + private float mLastTouchY; + + CustomGestureDetector(Context context, OnGestureListener listener) { + final ViewConfiguration configuration = ViewConfiguration.get(context); + mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); + mTouchSlop = configuration.getScaledTouchSlop(); + + mListener = listener; + ScaleGestureDetector.OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() { + + @Override + public boolean onScale(ScaleGestureDetector detector) { + float scaleFactor = detector.getScaleFactor(); + + if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) { + return false; + } + + if (scaleFactor >= 0) { + mListener.onScale(scaleFactor, detector.getFocusX(), detector.getFocusY()); + } + return true; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + // NO-OP + } + }; + mDetector = new ScaleGestureDetector(context, mScaleListener); + } + + private float getActiveX(MotionEvent ev) { + try { + return ev.getX(mActivePointerIndex); + } catch (Exception e) { + return ev.getX(); + } + } + + private float getActiveY(MotionEvent ev) { + try { + return ev.getY(mActivePointerIndex); + } catch (Exception e) { + return ev.getY(); + } + } + + public boolean isScaling() { + return mDetector.isInProgress(); + } + + public boolean isDragging() { + return mIsDragging; + } + + public boolean onTouchEvent(MotionEvent ev) { + try { + mDetector.onTouchEvent(ev); + return processTouchEvent(ev); + } catch (IllegalArgumentException e) { + // Fix for support lib bug, happening when onDestroy is called + return true; + } + } + + private boolean processTouchEvent(MotionEvent ev) { + final int action = ev.getAction(); + switch (action & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = ev.getPointerId(0); + + mVelocityTracker = VelocityTracker.obtain(); + if (null != mVelocityTracker) { + mVelocityTracker.addMovement(ev); + } + + mLastTouchX = getActiveX(ev); + mLastTouchY = getActiveY(ev); + mIsDragging = false; + break; + case MotionEvent.ACTION_MOVE: + final float x = getActiveX(ev); + final float y = getActiveY(ev); + final float dx = x - mLastTouchX, dy = y - mLastTouchY; + + if (!mIsDragging) { + // Use Pythagoras to see if drag length is larger than + // touch slop + mIsDragging = Math.sqrt((dx * dx) + (dy * dy)) >= mTouchSlop; + } + + if (mIsDragging) { + mListener.onDrag(dx, dy); + mLastTouchX = x; + mLastTouchY = y; + + if (null != mVelocityTracker) { + mVelocityTracker.addMovement(ev); + } + } + break; + case MotionEvent.ACTION_CANCEL: + mActivePointerId = INVALID_POINTER_ID; + // Recycle Velocity Tracker + if (null != mVelocityTracker) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + break; + case MotionEvent.ACTION_UP: + mActivePointerId = INVALID_POINTER_ID; + if (mIsDragging) { + if (null != mVelocityTracker) { + mLastTouchX = getActiveX(ev); + mLastTouchY = getActiveY(ev); + + // Compute velocity within the last 1000ms + mVelocityTracker.addMovement(ev); + mVelocityTracker.computeCurrentVelocity(1000); + + final float vX = mVelocityTracker.getXVelocity(), vY = mVelocityTracker.getYVelocity(); + + // If the velocity is greater than minVelocity, call + // listener + if (Math.max(Math.abs(vX), Math.abs(vY)) >= mMinimumVelocity) { + mListener.onFling(mLastTouchX, mLastTouchY, -vX, -vY); + } + } + } + + // Recycle Velocity Tracker + if (null != mVelocityTracker) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + break; + case MotionEvent.ACTION_POINTER_UP: + final int pointerIndex = Util.getPointerIndex(ev.getAction()); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = ev.getPointerId(newPointerIndex); + mLastTouchX = ev.getX(newPointerIndex); + mLastTouchY = ev.getY(newPointerIndex); + } + break; + } + + mActivePointerIndex = ev.findPointerIndex(mActivePointerId != INVALID_POINTER_ID ? mActivePointerId : 0); + return true; + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnGestureListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnGestureListener.java new file mode 100644 index 0000000..e29716e --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnGestureListener.java @@ -0,0 +1,25 @@ +/* + Copyright 2011, 2012 Chris Banes. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package cc.shinichi.library.view.photoview; + +interface OnGestureListener { + + void onDrag(float dx, float dy); + + void onFling(float startX, float startY, float velocityX, float velocityY); + + void onScale(float scaleFactor, float focusX, float focusY); +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnMatrixChangedListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnMatrixChangedListener.java new file mode 100644 index 0000000..ac75f2d --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnMatrixChangedListener.java @@ -0,0 +1,18 @@ +package cc.shinichi.library.view.photoview; + +import android.graphics.RectF; + +/** + * Interface definition for a callback to be invoked when the internal Matrix has changed for + * this View. + */ +public interface OnMatrixChangedListener { + + /** + * Callback for when the Matrix displaying the Drawable has changed. This could be because + * the View's bounds have changed, or the user has zoomed. + * + * @param rect - Rectangle displaying the Drawable's new bounds. + */ + void onMatrixChanged(RectF rect); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnOutsidePhotoTapListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnOutsidePhotoTapListener.java new file mode 100644 index 0000000..2273e46 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnOutsidePhotoTapListener.java @@ -0,0 +1,14 @@ +package cc.shinichi.library.view.photoview; + +import android.widget.ImageView; + +/** + * Callback when the user tapped outside of the photo + */ +public interface OnOutsidePhotoTapListener { + + /** + * The outside of the photo has been tapped + */ + void onOutsidePhotoTap(ImageView imageView); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnPhotoTapListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnPhotoTapListener.java new file mode 100644 index 0000000..50aa200 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnPhotoTapListener.java @@ -0,0 +1,22 @@ +package cc.shinichi.library.view.photoview; + +import android.widget.ImageView; + +/** + * A callback to be invoked when the Photo is tapped with a single + * tap. + */ +public interface OnPhotoTapListener { + + /** + * A callback to receive where the user taps on a photo. You will only receive a callback if + * the user taps on the actual photo, tapping on 'whitespace' will be ignored. + * + * @param view ImageView the user tapped. + * @param x where the user tapped from the of the Drawable, as percentage of the + * Drawable width. + * @param y where the user tapped from the top of the Drawable, as percentage of the + * Drawable height. + */ + void onPhotoTap(ImageView view, float x, float y); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnScaleChangedListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnScaleChangedListener.java new file mode 100644 index 0000000..443c72f --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnScaleChangedListener.java @@ -0,0 +1,16 @@ +package cc.shinichi.library.view.photoview; + +/** + * Interface definition for callback to be invoked when attached ImageView scale changes + */ +public interface OnScaleChangedListener { + + /** + * Callback for when the scale changes + * + * @param scaleFactor the scale factor (less than 1 for zoom out, greater than 1 for zoom in) + * @param focusX focal point X position + * @param focusY focal point Y position + */ + void onScaleChange(float scaleFactor, float focusX, float focusY); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnSingleFlingListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnSingleFlingListener.java new file mode 100644 index 0000000..d86cf6d --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnSingleFlingListener.java @@ -0,0 +1,21 @@ +package cc.shinichi.library.view.photoview; + +import android.view.MotionEvent; + +/** + * A callback to be invoked when the ImageView is flung with a single + * touch + */ +public interface OnSingleFlingListener { + + /** + * A callback to receive where the user flings on a ImageView. You will receive a callback if + * the user flings anywhere on the view. + * + * @param e1 MotionEvent the user first touch. + * @param e2 MotionEvent the user last touch. + * @param velocityX distance of user's horizontal fling. + * @param velocityY distance of user's vertical fling. + */ + boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnViewDragListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnViewDragListener.java new file mode 100644 index 0000000..c14a068 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnViewDragListener.java @@ -0,0 +1,16 @@ +package cc.shinichi.library.view.photoview; + +/** + * Interface definition for a callback to be invoked when the photo is experiencing a drag event + */ +public interface OnViewDragListener { + + /** + * Callback for when the photo is experiencing a drag event. This cannot be invoked when the + * user is scaling. + * + * @param dx The change of the coordinates in the x-direction + * @param dy The change of the coordinates in the y-direction + */ + void onDrag(float dx, float dy); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/OnViewTapListener.java b/library/src/main/java/cc/shinichi/library/view/photoview/OnViewTapListener.java new file mode 100644 index 0000000..a8262c1 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/OnViewTapListener.java @@ -0,0 +1,16 @@ +package cc.shinichi.library.view.photoview; + +import android.view.View; + +public interface OnViewTapListener { + + /** + * A callback to receive where the user taps on a ImageView. You will receive a callback if + * the user taps anywhere on the view, tapping on 'whitespace' will not be ignored. + * + * @param view - View the user tapped. + * @param x - where the user tapped from the left of the View. + * @param y - where the user tapped from the top of the View. + */ + void onViewTap(View view, float x, float y); +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/PhotoView.java b/library/src/main/java/cc/shinichi/library/view/photoview/PhotoView.java new file mode 100644 index 0000000..7669452 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/PhotoView.java @@ -0,0 +1,286 @@ +/* + Copyright 2011, 2012 Chris Banes. + <p> + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + <p> + http://www.apache.org/licenses/LICENSE-2.0 + <p> + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package cc.shinichi.library.view.photoview; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatImageView; + +/** + * A zoomable ImageView. See {@link PhotoViewAttacher} for most of the details on how the zooming + * is accomplished + */ +@SuppressWarnings("unused") +public class PhotoView extends AppCompatImageView { + + private PhotoViewAttacher attacher; + private ScaleType pendingScaleType; + // Max touches used in current gesture + private int maxTouchCount; + + public PhotoView(Context context) { + this(context, null); + } + + public PhotoView(Context context, AttributeSet attr) { + this(context, attr, 0); + } + + public PhotoView(Context context, AttributeSet attr, int defStyle) { + super(context, attr, defStyle); + init(); + } + + private void init() { + attacher = new PhotoViewAttacher(this); + //We always pose as a Matrix scale type, though we can change to another scale type + //via the attacher + super.setScaleType(ScaleType.MATRIX); + //apply the previously applied scale type + if (pendingScaleType != null) { + setScaleType(pendingScaleType); + pendingScaleType = null; + } + } + + /** + * Get the current {@link PhotoViewAttacher} for this view. Be wary of holding on to references + * to this attacher, as it has a reference to this view, which, if a reference is held in the + * wrong place, can cause memory leaks. + * + * @return the attacher. + */ + public PhotoViewAttacher getAttacher() { + return attacher; + } + + @Override + public ScaleType getScaleType() { + return attacher.getScaleType(); + } + + @Override + public void setScaleType(ScaleType scaleType) { + if (attacher == null) { + pendingScaleType = scaleType; + } else { + attacher.setScaleType(scaleType); + } + } + + @Override + public Matrix getImageMatrix() { + return attacher.getImageMatrix(); + } + + @Override + public void setOnLongClickListener(OnLongClickListener l) { + attacher.setOnLongClickListener(l); + } + + @Override + public void setOnClickListener(OnClickListener l) { + attacher.setOnClickListener(l); + } + + @Override + public void setImageDrawable(Drawable drawable) { + super.setImageDrawable(drawable); + // setImageBitmap calls through to this method + if (attacher != null) { + attacher.update(); + } + } + + @Override + public void setImageResource(int resId) { + super.setImageResource(resId); + if (attacher != null) { + attacher.update(); + } + } + + @Override + public void setImageURI(Uri uri) { + super.setImageURI(uri); + if (attacher != null) { + attacher.update(); + } + } + + @Override + protected boolean setFrame(int l, int t, int r, int b) { + boolean changed = super.setFrame(l, t, r, b); + if (changed) { + attacher.update(); + } + return changed; + } + + public void setRotationTo(float rotationDegree) { + attacher.setRotationTo(rotationDegree); + } + + public void setRotationBy(float rotationDegree) { + attacher.setRotationBy(rotationDegree); + } + + public boolean isZoomable() { + return attacher.isZoomable(); + } + + public void setZoomable(boolean zoomable) { + attacher.setZoomable(zoomable); + } + + public RectF getDisplayRect() { + return attacher.getDisplayRect(); + } + + public void getDisplayMatrix(Matrix matrix) { + attacher.getDisplayMatrix(matrix); + } + + @SuppressWarnings("UnusedReturnValue") + public boolean setDisplayMatrix(Matrix finalRectangle) { + return attacher.setDisplayMatrix(finalRectangle); + } + + public void getSuppMatrix(Matrix matrix) { + attacher.getSuppMatrix(matrix); + } + + public boolean setSuppMatrix(Matrix matrix) { + return attacher.setDisplayMatrix(matrix); + } + + public float getMinimumScale() { + return attacher.getMinimumScale(); + } + + public void setMinimumScale(float minimumScale) { + attacher.setMinimumScale(minimumScale); + } + + public float getMediumScale() { + return attacher.getMediumScale(); + } + + public void setMediumScale(float mediumScale) { + attacher.setMediumScale(mediumScale); + } + + public float getMaximumScale() { + return attacher.getMaximumScale(); + } + + public void setMaximumScale(float maximumScale) { + attacher.setMaximumScale(maximumScale); + } + + public float getScale() { + return attacher.getScale(); + } + + public void setScale(float scale) { + attacher.setScale(scale); + } + + public void setAllowParentInterceptOnEdge(boolean allow) { + attacher.setAllowParentInterceptOnEdge(allow); + } + + public void setScaleLevels(float minimumScale, float mediumScale, float maximumScale) { + attacher.setScaleLevels(minimumScale, mediumScale, maximumScale); + } + + public void setOnMatrixChangeListener(OnMatrixChangedListener listener) { + attacher.setOnMatrixChangeListener(listener); + } + + public void setOnPhotoTapListener(OnPhotoTapListener listener) { + attacher.setOnPhotoTapListener(listener); + } + + public void setOnOutsidePhotoTapListener(OnOutsidePhotoTapListener listener) { + attacher.setOnOutsidePhotoTapListener(listener); + } + + public void setOnViewTapListener(OnViewTapListener listener) { + attacher.setOnViewTapListener(listener); + } + + public void setOnViewDragListener(OnViewDragListener listener) { + attacher.setOnViewDragListener(listener); + } + + public void setScale(float scale, boolean animate) { + attacher.setScale(scale, animate); + } + + public void setScale(float scale, float focalX, float focalY, boolean animate) { + attacher.setScale(scale, focalX, focalY, animate); + } + + public void setZoomTransitionDuration(int milliseconds) { + attacher.setZoomTransitionDuration(milliseconds); + } + + public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener onDoubleTapListener) { + attacher.setOnDoubleTapListener(onDoubleTapListener); + } + + public void setOnScaleChangeListener(OnScaleChangedListener onScaleChangedListener) { + attacher.setOnScaleChangeListener(onScaleChangedListener); + } + + public void setOnSingleFlingListener(OnSingleFlingListener onSingleFlingListener) { + attacher.setOnSingleFlingListener(onSingleFlingListener); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + boolean handled = onTouchEventInternal(event); + return handled || super.onTouchEvent(event); + } + + @SuppressWarnings("deprecation") + private boolean onTouchEventInternal(@NonNull MotionEvent event) { + int touchCount = event.getPointerCount(); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_1_DOWN: + case MotionEvent.ACTION_POINTER_2_DOWN: + maxTouchCount = Math.max(maxTouchCount, touchCount); + return true; + default: + break; + } + return false; + } + + public int getMaxTouchCount() { + return maxTouchCount; + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/PhotoViewAttacher.java b/library/src/main/java/cc/shinichi/library/view/photoview/PhotoViewAttacher.java new file mode 100644 index 0000000..5d140e1 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/PhotoViewAttacher.java @@ -0,0 +1,789 @@ +/* + Copyright 2011, 2012 Chris Banes. + <p> + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + <p> + http://www.apache.org/licenses/LICENSE-2.0 + <p> + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package cc.shinichi.library.view.photoview; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Matrix.ScaleToFit; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnLongClickListener; +import android.view.ViewParent; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import android.widget.OverScroller; + +/** + * The component of {@link PhotoView} which does the work allowing for zooming, scaling, panning, etc. + * It is made public in case you need to subclass something other than AppCompatImageView and still + * gain the functionality that {@link PhotoView} offers + */ +public class PhotoViewAttacher implements View.OnTouchListener, View.OnLayoutChangeListener { + + private static final int HORIZONTAL_EDGE_NONE = -1; + private static final int HORIZONTAL_EDGE_LEFT = 0; + private static final int HORIZONTAL_EDGE_RIGHT = 1; + private static final int HORIZONTAL_EDGE_BOTH = 2; + private static final int VERTICAL_EDGE_NONE = -1; + private static final int VERTICAL_EDGE_TOP = 0; + private static final int VERTICAL_EDGE_BOTTOM = 1; + private static final int VERTICAL_EDGE_BOTH = 2; + private static final float DEFAULT_MAX_SCALE = 3.0f; + private static final float DEFAULT_MID_SCALE = 1.75f; + private static final float DEFAULT_MIN_SCALE = 1.0f; + private static final int DEFAULT_ZOOM_DURATION = 200; + private static final int SINGLE_TOUCH = 1; + // These are set so we don't keep allocating them on the heap + private final Matrix mBaseMatrix = new Matrix(); + private final Matrix mDrawMatrix = new Matrix(); + private final Matrix mSuppMatrix = new Matrix(); + private final RectF mDisplayRect = new RectF(); + private final float[] mMatrixValues = new float[9]; + private final ImageView mImageView; + private Interpolator mInterpolator = new AccelerateDecelerateInterpolator(); + private int mZoomDuration = DEFAULT_ZOOM_DURATION; + private float mMinScale = DEFAULT_MIN_SCALE; + private float mMidScale = DEFAULT_MID_SCALE; + private float mMaxScale = DEFAULT_MAX_SCALE; + private boolean mAllowParentInterceptOnEdge = true; + private boolean mBlockParentIntercept = false; + // Gesture Detectors + private GestureDetector mGestureDetector; + private CustomGestureDetector mScaleDragDetector; + // Listeners + private OnMatrixChangedListener mMatrixChangeListener; + private OnPhotoTapListener mPhotoTapListener; + private OnOutsidePhotoTapListener mOutsidePhotoTapListener; + private OnViewTapListener mViewTapListener; + private View.OnClickListener mOnClickListener; + private OnLongClickListener mLongClickListener; + private OnScaleChangedListener mScaleChangeListener; + private OnSingleFlingListener mSingleFlingListener; + private OnViewDragListener mOnViewDragListener; + + private FlingRunnable mCurrentFlingRunnable; + private int mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH; + private int mVerticalScrollEdge = VERTICAL_EDGE_BOTH; + private float mBaseRotation; + + private boolean mZoomEnabled = true; + private ScaleType mScaleType = ScaleType.FIT_CENTER; + + private final OnGestureListener onGestureListener = new OnGestureListener() { + @Override + public void onDrag(float dx, float dy) { + if (mScaleDragDetector.isScaling()) { + return; // Do not drag if we are already scaling + } + if (mOnViewDragListener != null) { + mOnViewDragListener.onDrag(dx, dy); + } + mSuppMatrix.postTranslate(dx, dy); + checkAndDisplayMatrix(); + + /* + * Here we decide whether to let the ImageView's parent to start taking + * over the touch event. + * + * First we check whether this function is enabled. We never want the + * parent to take over if we're scaling. We then check the edge we're + * on, and the direction of the scroll (i.e. if we're pulling against + * the edge, aka 'overscrolling', let the parent take over). + */ + 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); + } + } + } + + @Override + public void onFling(float startX, float startY, float velocityX, float velocityY) { + mCurrentFlingRunnable = new FlingRunnable(mImageView.getContext()); + mCurrentFlingRunnable.fling(getImageViewWidth(mImageView), getImageViewHeight(mImageView), (int) velocityX, + (int) velocityY); + mImageView.post(mCurrentFlingRunnable); + } + + @Override + public void onScale(float scaleFactor, float focusX, float focusY) { + if (getScale() < mMaxScale || scaleFactor < 1f) { + if (mScaleChangeListener != null) { + mScaleChangeListener.onScaleChange(scaleFactor, focusX, focusY); + } + mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY); + checkAndDisplayMatrix(); + } + } + }; + + public PhotoViewAttacher(ImageView imageView) { + mImageView = imageView; + imageView.setOnTouchListener(this); + imageView.addOnLayoutChangeListener(this); + if (imageView.isInEditMode()) { + return; + } + mBaseRotation = 0.0f; + // Create Gesture Detectors... + mScaleDragDetector = new CustomGestureDetector(imageView.getContext(), onGestureListener); + mGestureDetector = new GestureDetector(imageView.getContext(), new GestureDetector.SimpleOnGestureListener() { + + // forward long click listener + @Override + public void onLongPress(MotionEvent e) { + if (mLongClickListener != null) { + mLongClickListener.onLongClick(mImageView); + } + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (mSingleFlingListener != null) { + if (getScale() > DEFAULT_MIN_SCALE) { + return false; + } + if (e1.getPointerCount() > SINGLE_TOUCH || e2.getPointerCount() > SINGLE_TOUCH) { + return false; + } + return mSingleFlingListener.onFling(e1, e2, velocityX, velocityY); + } + return false; + } + }); + mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + if (mOnClickListener != null) { + mOnClickListener.onClick(mImageView); + } + final RectF displayRect = getDisplayRect(); + final float x = e.getX(), y = e.getY(); + if (mViewTapListener != null) { + mViewTapListener.onViewTap(mImageView, x, y); + } + if (displayRect != null) { + // Check to see if the user tapped on the photo + if (displayRect.contains(x, y)) { + float xResult = (x - displayRect.left) / displayRect.width(); + float yResult = (y - displayRect.top) / displayRect.height(); + if (mPhotoTapListener != null) { + mPhotoTapListener.onPhotoTap(mImageView, xResult, yResult); + } + return true; + } else { + if (mOutsidePhotoTapListener != null) { + mOutsidePhotoTapListener.onOutsidePhotoTap(mImageView); + } + } + } + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent ev) { + try { + float scale = getScale(); + float x = ev.getX(); + float y = ev.getY(); + if (scale < getMediumScale()) { + setScale(getMediumScale(), x, y, true); + } else if (scale >= getMediumScale() && scale < getMaximumScale()) { + setScale(getMaximumScale(), x, y, true); + } else { + setScale(getMinimumScale(), x, y, true); + } + } catch (ArrayIndexOutOfBoundsException e) { + // Can sometimes happen when getX() and getY() is called + } + return true; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + // Wait for the confirmed onDoubleTap() instead + return false; + } + }); + } + + public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener newOnDoubleTapListener) { + this.mGestureDetector.setOnDoubleTapListener(newOnDoubleTapListener); + } + + public void setOnScaleChangeListener(OnScaleChangedListener onScaleChangeListener) { + this.mScaleChangeListener = onScaleChangeListener; + } + + public void setOnSingleFlingListener(OnSingleFlingListener onSingleFlingListener) { + this.mSingleFlingListener = onSingleFlingListener; + } + + @Deprecated + public boolean isZoomEnabled() { + return mZoomEnabled; + } + + public RectF getDisplayRect() { + checkMatrixBounds(); + return getDisplayRect(getDrawMatrix()); + } + + public boolean setDisplayMatrix(Matrix finalMatrix) { + if (finalMatrix == null) { + throw new IllegalArgumentException("Matrix cannot be null"); + } + if (mImageView.getDrawable() == null) { + return false; + } + mSuppMatrix.set(finalMatrix); + checkAndDisplayMatrix(); + return true; + } + + public void setBaseRotation(final float degrees) { + mBaseRotation = degrees % 360; + update(); + setRotationBy(mBaseRotation); + checkAndDisplayMatrix(); + } + + public void setRotationTo(float degrees) { + mSuppMatrix.setRotate(degrees % 360); + checkAndDisplayMatrix(); + } + + public void setRotationBy(float degrees) { + mSuppMatrix.postRotate(degrees % 360); + checkAndDisplayMatrix(); + } + + public float getMinimumScale() { + return mMinScale; + } + + public void setMinimumScale(float minimumScale) { + Util.checkZoomLevels(minimumScale, mMidScale, mMaxScale); + mMinScale = minimumScale; + } + + public float getMediumScale() { + return mMidScale; + } + + public void setMediumScale(float mediumScale) { + Util.checkZoomLevels(mMinScale, mediumScale, mMaxScale); + mMidScale = mediumScale; + } + + public float getMaximumScale() { + return mMaxScale; + } + + public void setMaximumScale(float maximumScale) { + Util.checkZoomLevels(mMinScale, mMidScale, maximumScale); + mMaxScale = maximumScale; + } + + public float getScale() { + return (float) Math.sqrt((float) Math.pow(getValue(mSuppMatrix, Matrix.MSCALE_X), 2) + (float) Math.pow( + getValue(mSuppMatrix, Matrix.MSKEW_Y), 2)); + } + + public void setScale(float scale) { + setScale(scale, false); + } + + public ScaleType getScaleType() { + return mScaleType; + } + + public void setScaleType(ScaleType scaleType) { + if (Util.isSupportedScaleType(scaleType) && scaleType != mScaleType) { + mScaleType = scaleType; + update(); + } + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, + int oldBottom) { + // Update our base matrix, as the bounds have changed + if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { + updateBaseMatrix(mImageView.getDrawable()); + } + } + + @Override + public boolean onTouch(View v, MotionEvent ev) { + boolean handled = false; + if (mZoomEnabled && Util.hasDrawable((ImageView) v)) { + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + ViewParent parent = v.getParent(); + // First, disable the Parent from intercepting the touch + // event + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + // If we're flinging, and the user presses down, cancel + // fling + cancelFling(); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + // If the user has zoomed less than min scale, zoom back + // to min scale + if (getScale() < mMinScale) { + RectF rect = getDisplayRect(); + if (rect != null) { + v.post(new AnimatedZoomRunnable(getScale(), mMinScale, rect.centerX(), rect.centerY())); + handled = true; + } + } else if (getScale() > mMaxScale) { + RectF rect = getDisplayRect(); + if (rect != null) { + v.post(new AnimatedZoomRunnable(getScale(), mMaxScale, rect.centerX(), rect.centerY())); + handled = true; + } + } + break; + } + // Try the Scale/Drag detector + if (mScaleDragDetector != null) { + boolean wasScaling = mScaleDragDetector.isScaling(); + boolean wasDragging = mScaleDragDetector.isDragging(); + handled = mScaleDragDetector.onTouchEvent(ev); + boolean didntScale = !wasScaling && !mScaleDragDetector.isScaling(); + boolean didntDrag = !wasDragging && !mScaleDragDetector.isDragging(); + mBlockParentIntercept = didntScale && didntDrag; + } + // Check to see if the user double tapped + if (mGestureDetector != null && mGestureDetector.onTouchEvent(ev)) { + handled = true; + } + } + return handled; + } + + public void setAllowParentInterceptOnEdge(boolean allow) { + mAllowParentInterceptOnEdge = allow; + } + + public void setScaleLevels(float minimumScale, float mediumScale, float maximumScale) { + Util.checkZoomLevels(minimumScale, mediumScale, maximumScale); + mMinScale = minimumScale; + mMidScale = mediumScale; + mMaxScale = maximumScale; + } + + public void setOnLongClickListener(OnLongClickListener listener) { + mLongClickListener = listener; + } + + public void setOnClickListener(View.OnClickListener listener) { + mOnClickListener = listener; + } + + public void setOnMatrixChangeListener(OnMatrixChangedListener listener) { + mMatrixChangeListener = listener; + } + + public void setOnPhotoTapListener(OnPhotoTapListener listener) { + mPhotoTapListener = listener; + } + + public void setOnOutsidePhotoTapListener(OnOutsidePhotoTapListener mOutsidePhotoTapListener) { + this.mOutsidePhotoTapListener = mOutsidePhotoTapListener; + } + + public void setOnViewTapListener(OnViewTapListener listener) { + mViewTapListener = listener; + } + + public void setOnViewDragListener(OnViewDragListener listener) { + mOnViewDragListener = listener; + } + + public void setScale(float scale, boolean animate) { + setScale(scale, (mImageView.getRight()) / 2, (mImageView.getBottom()) / 2, animate); + } + + public void setScale(float scale, float focalX, float focalY, boolean animate) { + // Check to see if the scale is within bounds + if (scale < mMinScale || scale > mMaxScale) { + throw new IllegalArgumentException("Scale must be within the range of minScale and maxScale"); + } + if (animate) { + mImageView.post(new AnimatedZoomRunnable(getScale(), scale, focalX, focalY)); + } else { + mSuppMatrix.setScale(scale, scale, focalX, focalY); + checkAndDisplayMatrix(); + } + } + + /** + * Set the zoom interpolator + * + * @param interpolator the zoom interpolator + */ + public void setZoomInterpolator(Interpolator interpolator) { + mInterpolator = interpolator; + } + + public boolean isZoomable() { + return mZoomEnabled; + } + + public void setZoomable(boolean zoomable) { + mZoomEnabled = zoomable; + update(); + } + + public void update() { + if (mZoomEnabled) { + // Update the base matrix using the current drawable + updateBaseMatrix(mImageView.getDrawable()); + } else { + // Reset the Matrix... + resetMatrix(); + } + } + + /** + * Get the display matrix + * + * @param matrix target matrix to copy to + */ + public void getDisplayMatrix(Matrix matrix) { + matrix.set(getDrawMatrix()); + } + + /** + * Get the current support matrix + */ + public void getSuppMatrix(Matrix matrix) { + matrix.set(mSuppMatrix); + } + + private Matrix getDrawMatrix() { + mDrawMatrix.set(mBaseMatrix); + mDrawMatrix.postConcat(mSuppMatrix); + return mDrawMatrix; + } + + public Matrix getImageMatrix() { + return mDrawMatrix; + } + + public void setZoomTransitionDuration(int milliseconds) { + this.mZoomDuration = milliseconds; + } + + /** + * Helper method that 'unpacks' a Matrix and returns the required value + * + * @param matrix Matrix to unpack + * @param whichValue Which value from Matrix.M* to return + * @return returned value + */ + private float getValue(Matrix matrix, int whichValue) { + matrix.getValues(mMatrixValues); + return mMatrixValues[whichValue]; + } + + /** + * Resets the Matrix back to FIT_CENTER, and then displays its contents + */ + private void resetMatrix() { + mSuppMatrix.reset(); + setRotationBy(mBaseRotation); + setImageViewMatrix(getDrawMatrix()); + checkMatrixBounds(); + } + + private void setImageViewMatrix(Matrix matrix) { + mImageView.setImageMatrix(matrix); + // Call MatrixChangedListener if needed + if (mMatrixChangeListener != null) { + RectF displayRect = getDisplayRect(matrix); + if (displayRect != null) { + mMatrixChangeListener.onMatrixChanged(displayRect); + } + } + } + + /** + * Helper method that simply checks the Matrix, and then displays the result + */ + private void checkAndDisplayMatrix() { + if (checkMatrixBounds()) { + setImageViewMatrix(getDrawMatrix()); + } + } + + /** + * Helper method that maps the supplied Matrix to the current Drawable + * + * @param matrix - Matrix to map Drawable against + * @return RectF - Displayed Rectangle + */ + private RectF getDisplayRect(Matrix matrix) { + Drawable d = mImageView.getDrawable(); + if (d != null) { + mDisplayRect.set(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight()); + matrix.mapRect(mDisplayRect); + return mDisplayRect; + } + return null; + } + + /** + * Calculate Matrix for FIT_CENTER + * + * @param drawable - Drawable being displayed + */ + private void updateBaseMatrix(Drawable drawable) { + if (drawable == null) { + return; + } + final float viewWidth = getImageViewWidth(mImageView); + final float viewHeight = getImageViewHeight(mImageView); + final int drawableWidth = drawable.getIntrinsicWidth(); + final int drawableHeight = drawable.getIntrinsicHeight(); + mBaseMatrix.reset(); + final float widthScale = viewWidth / drawableWidth; + final float heightScale = viewHeight / drawableHeight; + if (mScaleType == ScaleType.CENTER) { + mBaseMatrix.postTranslate((viewWidth - drawableWidth) / 2F, (viewHeight - drawableHeight) / 2F); + } else if (mScaleType == ScaleType.CENTER_CROP) { + float scale = Math.max(widthScale, heightScale); + mBaseMatrix.postScale(scale, scale); + mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2F, + (viewHeight - drawableHeight * scale) / 2F); + } else if (mScaleType == ScaleType.CENTER_INSIDE) { + float scale = Math.min(1.0f, Math.min(widthScale, heightScale)); + mBaseMatrix.postScale(scale, scale); + mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2F, + (viewHeight - drawableHeight * scale) / 2F); + } else { + RectF mTempSrc = new RectF(0, 0, drawableWidth, drawableHeight); + RectF mTempDst = new RectF(0, 0, viewWidth, viewHeight); + if ((int) mBaseRotation % 180 != 0) { + mTempSrc = new RectF(0, 0, drawableHeight, drawableWidth); + } + switch (mScaleType) { + case FIT_CENTER: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.CENTER); + break; + case FIT_START: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.START); + break; + case FIT_END: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.END); + break; + case FIT_XY: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.FILL); + break; + default: + break; + } + } + resetMatrix(); + } + + private boolean checkMatrixBounds() { + final RectF rect = getDisplayRect(getDrawMatrix()); + if (rect == null) { + return false; + } + final float height = rect.height(), width = rect.width(); + float deltaX = 0, deltaY = 0; + final int viewHeight = getImageViewHeight(mImageView); + if (height <= viewHeight) { + switch (mScaleType) { + case FIT_START: + deltaY = -rect.top; + break; + case FIT_END: + deltaY = viewHeight - height - rect.top; + break; + default: + deltaY = (viewHeight - height) / 2 - rect.top; + break; + } + mVerticalScrollEdge = VERTICAL_EDGE_BOTH; + } else if (rect.top > 0) { + mVerticalScrollEdge = VERTICAL_EDGE_TOP; + deltaY = -rect.top; + } else if (rect.bottom < viewHeight) { + mVerticalScrollEdge = VERTICAL_EDGE_BOTTOM; + deltaY = viewHeight - rect.bottom; + } else { + mVerticalScrollEdge = VERTICAL_EDGE_NONE; + } + final int viewWidth = getImageViewWidth(mImageView); + if (width <= viewWidth) { + switch (mScaleType) { + case FIT_START: + deltaX = -rect.left; + break; + case FIT_END: + deltaX = viewWidth - width - rect.left; + break; + default: + deltaX = (viewWidth - width) / 2 - rect.left; + break; + } + mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH; + } else if (rect.left > 0) { + mHorizontalScrollEdge = HORIZONTAL_EDGE_LEFT; + deltaX = -rect.left; + } else if (rect.right < viewWidth) { + deltaX = viewWidth - rect.right; + mHorizontalScrollEdge = HORIZONTAL_EDGE_RIGHT; + } else { + mHorizontalScrollEdge = HORIZONTAL_EDGE_NONE; + } + // Finally actually translate the matrix + mSuppMatrix.postTranslate(deltaX, deltaY); + return true; + } + + private int getImageViewWidth(ImageView imageView) { + return imageView.getWidth() - imageView.getPaddingLeft() - imageView.getPaddingRight(); + } + + private int getImageViewHeight(ImageView imageView) { + return imageView.getHeight() - imageView.getPaddingTop() - imageView.getPaddingBottom(); + } + + private void cancelFling() { + if (mCurrentFlingRunnable != null) { + mCurrentFlingRunnable.cancelFling(); + mCurrentFlingRunnable = null; + } + } + + private class AnimatedZoomRunnable implements Runnable { + + private final float mFocalX, mFocalY; + private final long mStartTime; + private final float mZoomStart, mZoomEnd; + + public AnimatedZoomRunnable(final float currentZoom, final float targetZoom, final float focalX, + final float focalY) { + mFocalX = focalX; + mFocalY = focalY; + mStartTime = System.currentTimeMillis(); + mZoomStart = currentZoom; + mZoomEnd = targetZoom; + } + + @Override + public void run() { + float t = interpolate(); + float scale = mZoomStart + t * (mZoomEnd - mZoomStart); + float deltaScale = scale / getScale(); + onGestureListener.onScale(deltaScale, mFocalX, mFocalY); + // We haven't hit our target scale yet, so post ourselves again + if (t < 1f) { + Compat.postOnAnimation(mImageView, this); + } + } + + private float interpolate() { + float t = 1f * (System.currentTimeMillis() - mStartTime) / mZoomDuration; + t = Math.min(1f, t); + t = mInterpolator.getInterpolation(t); + return t; + } + } + + private class FlingRunnable implements Runnable { + + private final OverScroller mScroller; + private int mCurrentX, mCurrentY; + + public FlingRunnable(Context context) { + mScroller = new OverScroller(context); + } + + public void cancelFling() { + mScroller.forceFinished(true); + } + + public void fling(int viewWidth, int viewHeight, int velocityX, int velocityY) { + final RectF rect = getDisplayRect(); + if (rect == null) { + return; + } + final int startX = Math.round(-rect.left); + final int minX, maxX, minY, maxY; + if (viewWidth < rect.width()) { + minX = 0; + maxX = Math.round(rect.width() - viewWidth); + } else { + minX = maxX = startX; + } + final int startY = Math.round(-rect.top); + if (viewHeight < rect.height()) { + minY = 0; + maxY = Math.round(rect.height() - viewHeight); + } else { + minY = maxY = startY; + } + mCurrentX = startX; + mCurrentY = startY; + // If we actually can move, fling the scroller + if (startX != maxX || startY != maxY) { + mScroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY, 0, 0); + } + } + + @Override + public void run() { + if (mScroller.isFinished()) { + return; // remaining post that should not be handled + } + if (mScroller.computeScrollOffset()) { + final int newX = mScroller.getCurrX(); + final int newY = mScroller.getCurrY(); + mSuppMatrix.postTranslate(mCurrentX - newX, mCurrentY - newY); + checkAndDisplayMatrix(); + mCurrentX = newX; + mCurrentY = newY; + // Post On animation + Compat.postOnAnimation(mImageView, this); + } + } + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/photoview/Util.java b/library/src/main/java/cc/shinichi/library/view/photoview/Util.java new file mode 100644 index 0000000..0ddb8b2 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/photoview/Util.java @@ -0,0 +1,36 @@ +package cc.shinichi.library.view.photoview; + +import android.view.MotionEvent; +import android.widget.ImageView; + +class Util { + + static void checkZoomLevels(float minZoom, float midZoom, float maxZoom) { + if (minZoom >= midZoom) { + throw new IllegalArgumentException( + "Minimum zoom has to be less than Medium zoom. Call setMinimumZoom() with a more appropriate value"); + } else if (midZoom >= maxZoom) { + throw new IllegalArgumentException( + "Medium zoom has to be less than Maximum zoom. Call setMaximumZoom() with a more appropriate value"); + } + } + + static boolean hasDrawable(ImageView imageView) { + return imageView.getDrawable() != null; + } + + static boolean isSupportedScaleType(final ImageView.ScaleType scaleType) { + if (scaleType == null) { + return false; + } + switch (scaleType) { + case MATRIX: + throw new IllegalStateException("Matrix scale type is not supported"); + } + return true; + } + + static int getPointerIndex(int action) { + return (action & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/ImageSource.java b/library/src/main/java/cc/shinichi/library/view/subsampling/ImageSource.java new file mode 100644 index 0000000..88146ef --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/ImageSource.java @@ -0,0 +1,273 @@ +package cc.shinichi.library.view.subsampling; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +/** + * Helper class used to set the source and additional attributes from a variety of sources. Supports + * use of a bitmap, asset, resource, external file or any other URI. + * <p> + * When you are using a preview image, you must set the dimensions of the full size image on the + * ImageSource object for the full size image using the {@link #dimensions(int, int)} method. + */ +@SuppressWarnings({"unused", "WeakerAccess"}) +public final class ImageSource { + + public static final String FILE_SCHEME = "file:///"; + public static final String ASSET_SCHEME = "file:///android_asset/"; + + private final Uri uri; + private final Bitmap bitmap; + private final Integer resource; + private boolean tile; + private int sWidth; + private int sHeight; + private Rect sRegion; + private boolean cached; + + private ImageSource(Bitmap bitmap, boolean cached) { + this.bitmap = bitmap; + this.uri = null; + this.resource = null; + this.tile = false; + this.sWidth = bitmap.getWidth(); + this.sHeight = bitmap.getHeight(); + this.cached = cached; + } + + private ImageSource(@NonNull Uri uri) { + // #114 If file doesn't exist, attempt to url decode the URI and try again + String uriString = uri.toString(); + if (uriString.startsWith(FILE_SCHEME)) { + File uriFile = new File(uriString.substring(FILE_SCHEME.length() - 1)); + if (!uriFile.exists()) { + try { + uri = Uri.parse(URLDecoder.decode(uriString, "UTF-8")); + } catch (UnsupportedEncodingException e) { + // Fallback to encoded URI. This exception is not expected. + } + } + } + this.bitmap = null; + this.uri = uri; + this.resource = null; + this.tile = true; + } + + private ImageSource(int resource) { + this.bitmap = null; + this.uri = null; + this.resource = resource; + this.tile = true; + } + + /** + * Create an instance from a resource. The correct resource for the device screen resolution will be used. + * + * @param resId resource ID. + * @return an {@link ImageSource} instance. + */ + @NonNull + public static ImageSource resource(int resId) { + return new ImageSource(resId); + } + + /** + * Create an instance from an asset name. + * + * @param assetName asset name. + * @return an {@link ImageSource} instance. + */ + @NonNull + public static ImageSource asset(@NonNull String assetName) { + //noinspection ConstantConditions + if (assetName == null) { + throw new NullPointerException("Asset name must not be null"); + } + return uri(ASSET_SCHEME + assetName); + } + + /** + * Create an instance from a URI. If the URI does not start with a scheme, it's assumed to be the URI + * of a file. + * + * @param uri image URI. + * @return an {@link ImageSource} instance. + */ + @NonNull + public static ImageSource uri(@NonNull String uri) { + //noinspection ConstantConditions + if (uri == null) { + throw new NullPointerException("Uri must not be null"); + } + if (!uri.contains("://")) { + if (uri.startsWith("/")) { + uri = uri.substring(1); + } + uri = FILE_SCHEME + uri; + } + return new ImageSource(Uri.parse(uri)); + } + + /** + * Create an instance from a URI. + * + * @param uri image URI. + * @return an {@link ImageSource} instance. + */ + @NonNull + public static ImageSource uri(@NonNull Uri uri) { + //noinspection ConstantConditions + if (uri == null) { + throw new NullPointerException("Uri must not be null"); + } + return new ImageSource(uri); + } + + /** + * Provide a loaded bitmap for display. + * + * @param bitmap bitmap to be displayed. + * @return an {@link ImageSource} instance. + */ + @NonNull + public static ImageSource bitmap(@NonNull Bitmap bitmap) { + //noinspection ConstantConditions + if (bitmap == null) { + throw new NullPointerException("Bitmap must not be null"); + } + return new ImageSource(bitmap, false); + } + + /** + * Provide a loaded and cached bitmap for display. This bitmap will not be recycled when it is no + * longer needed. Use this method if you loaded the bitmap with an image loader such as Picasso + * or Volley. + * + * @param bitmap bitmap to be displayed. + * @return an {@link ImageSource} instance. + */ + @NonNull + public static ImageSource cachedBitmap(@NonNull Bitmap bitmap) { + //noinspection ConstantConditions + if (bitmap == null) { + throw new NullPointerException("Bitmap must not be null"); + } + return new ImageSource(bitmap, true); + } + + /** + * Enable tiling of the image. This does not apply to preview images which are always loaded as a single bitmap., + * and tiling cannot be disabled when displaying a region of the source image. + * + * @return this instance for chaining. + */ + @NonNull + public ImageSource tilingEnabled() { + return tiling(true); + } + + /** + * Disable tiling of the image. This does not apply to preview images which are always loaded as a single bitmap, + * and tiling cannot be disabled when displaying a region of the source image. + * + * @return this instance for chaining. + */ + @NonNull + public ImageSource tilingDisabled() { + return tiling(false); + } + + /** + * Enable or disable tiling of the image. This does not apply to preview images which are always loaded as a single bitmap, + * and tiling cannot be disabled when displaying a region of the source image. + * + * @param tile whether tiling should be enabled. + * @return this instance for chaining. + */ + @NonNull + public ImageSource tiling(boolean tile) { + this.tile = tile; + return this; + } + + /** + * Use a region of the source image. Region must be set independently for the full size image and the preview if + * you are using one. + * + * @param sRegion the region of the source image to be displayed. + * @return this instance for chaining. + */ + @NonNull + public ImageSource region(Rect sRegion) { + this.sRegion = sRegion; + setInvariants(); + return this; + } + + /** + * Declare the dimensions of the image. This is only required for a full size image, when you are specifying a URI + * and also a preview image. When displaying a bitmap object, or not using a preview, you do not need to declare + * the image dimensions. Note if the declared dimensions are found to be incorrect, the view will reset. + * + * @param sWidth width of the source image. + * @param sHeight height of the source image. + * @return this instance for chaining. + */ + @NonNull + public ImageSource dimensions(int sWidth, int sHeight) { + if (bitmap == null) { + this.sWidth = sWidth; + this.sHeight = sHeight; + } + setInvariants(); + return this; + } + + private void setInvariants() { + if (this.sRegion != null) { + this.tile = true; + this.sWidth = this.sRegion.width(); + this.sHeight = this.sRegion.height(); + } + } + + public final Uri getUri() { + return uri; + } + + public final Bitmap getBitmap() { + return bitmap; + } + + public final Integer getResource() { + return resource; + } + + public final boolean getTile() { + return tile; + } + + public final int getSWidth() { + return sWidth; + } + + public final int getSHeight() { + return sHeight; + } + + public final Rect getSRegion() { + return sRegion; + } + + public final boolean isCached() { + return cached; + } +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/ImageViewState.java b/library/src/main/java/cc/shinichi/library/view/subsampling/ImageViewState.java new file mode 100644 index 0000000..89e9175 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/ImageViewState.java @@ -0,0 +1,43 @@ +package cc.shinichi.library.view.subsampling; + +import android.graphics.PointF; + +import androidx.annotation.NonNull; + +import java.io.Serializable; + +/** + * Wraps the scale, center and orientation of a displayed image for easy restoration on screen rotate. + */ +@SuppressWarnings("WeakerAccess") +public class ImageViewState implements Serializable { + + private final float scale; + + private final float centerX; + + private final float centerY; + + private final int orientation; + + public ImageViewState(float scale, @NonNull PointF center, int orientation) { + this.scale = scale; + this.centerX = center.x; + this.centerY = center.y; + this.orientation = orientation; + } + + public float getScale() { + return scale; + } + + @NonNull + public PointF getCenter() { + return new PointF(centerX, centerY); + } + + public int getOrientation() { + return orientation; + } + +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/SubsamplingScaleImageView.java b/library/src/main/java/cc/shinichi/library/view/subsampling/SubsamplingScaleImageView.java new file mode 100644 index 0000000..f738702 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/SubsamplingScaleImageView.java @@ -0,0 +1,3381 @@ +package cc.shinichi.library.view.subsampling; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.TypedArray; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.Message; +import android.provider.MediaStore; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewParent; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import cc.shinichi.library.R; +import cc.shinichi.library.tool.common.SLog; +import cc.shinichi.library.view.subsampling.decoder.CompatDecoderFactory; +import cc.shinichi.library.view.subsampling.decoder.DecoderFactory; +import cc.shinichi.library.view.subsampling.decoder.ImageDecoder; +import cc.shinichi.library.view.subsampling.decoder.ImageRegionDecoder; +import cc.shinichi.library.view.subsampling.decoder.SkiaImageDecoder; +import cc.shinichi.library.view.subsampling.decoder.SkiaImageRegionDecoder; + +/** + * <p> + * Displays an image subsampled as necessary to avoid loading too much image data into memory. After zooming in, + * a set of image tiles subsampled at higher resolution are loaded and displayed over the base layer. During pan and + * zoom, tiles off screen or higher/lower resolution than required are discarded from memory. + * </p><p> + * Tiles are no larger than the max supported bitmap size, so with large images tiling may be used even when zoomed out. + * </p><p> + * v prefixes - coordinates, translations and distances measured in screen (view) pixels + * <br> + * s prefixes - coordinates, translations and distances measured in rotated and cropped source image pixels (scaled) + * <br> + * f prefixes - coordinates, translations and distances measured in original unrotated, uncropped source file pixels + * </p><p> + * <a href="https://github.com/davemorrissey/subsampling-scale-image-view">View project on GitHub</a> + * </p> + */ +@SuppressWarnings("unused") +public class SubsamplingScaleImageView extends View { + + /** + * Attempt to use EXIF information on the image to rotate it. Works for external files only. + */ + public static final int ORIENTATION_USE_EXIF = -1; + /** + * Display the image file in its native orientation. + */ + public static final int ORIENTATION_0 = 0; + /** + * Rotate the image 90 degrees clockwise. + */ + public static final int ORIENTATION_90 = 90; + /** + * Rotate the image 180 degrees. + */ + public static final int ORIENTATION_180 = 180; + /** + * Rotate the image 270 degrees clockwise. + */ + public static final int ORIENTATION_270 = 270; + /** + * During zoom animation, keep the point of the image that was tapped in the same place, and scale the image around it. + */ + public static final int ZOOM_FOCUS_FIXED = 1; + /** + * During zoom animation, move the point of the image that was tapped to the center of the screen. + */ + public static final int ZOOM_FOCUS_CENTER = 2; + /** + * Zoom in to and center the tapped point immediately without animating. + */ + public static final int ZOOM_FOCUS_CENTER_IMMEDIATE = 3; + /** + * Quadratic ease out. Not recommended for scale animation, but good for panning. + */ + public static final int EASE_OUT_QUAD = 1; + /** + * Quadratic ease in and out. + */ + public static final int EASE_IN_OUT_QUAD = 2; + /** + * Don't allow the image to be panned off screen. As much of the image as possible is always displayed, centered in the view when it is smaller. This is the best option for galleries. + */ + public static final int PAN_LIMIT_INSIDE = 1; + /** + * Allows the image to be panned until it is just off screen, but no further. The edge of the image will stop when it is flush with the screen edge. + */ + public static final int PAN_LIMIT_OUTSIDE = 2; + /** + * Allows the image to be panned until a corner reaches the center of the screen but no further. Useful when you want to pan any spot on the image to the exact center of the screen. + */ + public static final int PAN_LIMIT_CENTER = 3; + /** + * Scale the image so that both dimensions of the image will be equal to or less than the corresponding dimension of the view. The image is then centered in the view. This is the default behaviour and best for galleries. + */ + public static final int SCALE_TYPE_CENTER_INSIDE = 1; + /** + * Scale the image uniformly so that both dimensions of the image will be equal to or larger than the corresponding dimension of the view. The image is then centered in the view. + */ + public static final int SCALE_TYPE_CENTER_CROP = 2; + /** + * Scale the image so that both dimensions of the image will be equal to or less than the maxScale and equal to or larger than minScale. The image is then centered in the view. + */ + public static final int SCALE_TYPE_CUSTOM = 3; + /** + * Scale the image so that both dimensions of the image will be equal to or larger than the corresponding dimension of the view. The top left is shown. + */ + public static final int SCALE_TYPE_START = 4; + /** + * State change originated from animation. + */ + public static final int ORIGIN_ANIM = 1; + /** + * State change originated from touch gesture. + */ + public static final int ORIGIN_TOUCH = 2; + /** + * State change originated from a fling momentum anim. + */ + public static final int ORIGIN_FLING = 3; + /** + * State change originated from a double tap zoom anim. + */ + public static final int ORIGIN_DOUBLE_TAP_ZOOM = 4; + // overrides for the dimensions of the generated tiles + public static final int TILE_SIZE_AUTO = Integer.MAX_VALUE; + private static final String TAG = SubsamplingScaleImageView.class.getSimpleName(); + private static final List<Integer> VALID_ORIENTATIONS = Arrays.asList(ORIENTATION_0, ORIENTATION_90, ORIENTATION_180, ORIENTATION_270, ORIENTATION_USE_EXIF); + private static final List<Integer> VALID_ZOOM_STYLES = Arrays.asList(ZOOM_FOCUS_FIXED, ZOOM_FOCUS_CENTER, ZOOM_FOCUS_CENTER_IMMEDIATE); + private static final List<Integer> VALID_EASING_STYLES = Arrays.asList(EASE_IN_OUT_QUAD, EASE_OUT_QUAD); + private static final List<Integer> VALID_PAN_LIMITS = Arrays.asList(PAN_LIMIT_INSIDE, PAN_LIMIT_OUTSIDE, PAN_LIMIT_CENTER); + private static final List<Integer> VALID_SCALE_TYPES = Arrays.asList(SCALE_TYPE_CENTER_CROP, SCALE_TYPE_CENTER_INSIDE, SCALE_TYPE_CUSTOM, SCALE_TYPE_START); + private static final int MESSAGE_LONG_CLICK = 1; + // A global preference for bitmap format, available to decoder classes that respect it + private static Bitmap.Config preferredBitmapConfig; + private final ReadWriteLock decoderLock = new ReentrantReadWriteLock(true); + // Current quickscale state + private final float quickScaleThreshold; + // Long click handler + private final Handler handler; + private final float[] srcArray = new float[8]; + private final float[] dstArray = new float[8]; + //The logical density of the display + private final float density; + // Bitmap (preview or full image) + private Bitmap bitmap; // Min scale allowed (prevent infinite zoom) + private float minScale = minScale(); + // Whether the bitmap is a preview image + private boolean bitmapIsPreview; + // Specifies if a cache handler is also referencing the bitmap. Do not recycle if so. + private boolean bitmapIsCached; + // Uri of full size image + private Uri uri; + // Sample size used to display the whole image when fully zoomed out + private int fullImageSampleSize; + // Map of zoom level to tile grid + private Map<Integer, List<Tile>> tileMap; + // Overlay tile boundaries and other info + private boolean debug; + // Image orientation setting + private int orientation = ORIENTATION_0; + // Max scale allowed (prevent infinite zoom) + private float maxScale = 2F; + // Density to reach before loading higher resolution tiles + private int minimumTileDpi = -1; + // Pan limiting style + private int panLimit = PAN_LIMIT_INSIDE; + // Minimum scale type + private int minimumScaleType = SCALE_TYPE_CENTER_INSIDE; + private int maxTileWidth = TILE_SIZE_AUTO; + private int maxTileHeight = TILE_SIZE_AUTO; + // An executor service for loading of images + private Executor executor = AsyncTask.THREAD_POOL_EXECUTOR; + // Whether tiles should be loaded while gestures and animations are still in progress + private boolean eagerLoadingEnabled = true; + // Gesture detection settings + private boolean panEnabled = true; + private boolean zoomEnabled = true; + private boolean quickScaleEnabled = true; + // Double tap zoom behaviour + private float doubleTapZoomScale = 1F; + private int doubleTapZoomStyle = ZOOM_FOCUS_FIXED; + private int doubleTapZoomDuration = 500; + // Current scale and scale at start of zoom + private float scale; + private float scaleStart; + // Screen coordinate of top-left corner of source image + private PointF vTranslate; + private PointF vTranslateStart; + private PointF vTranslateBefore; + // Source coordinate to center on, used when new position is set externally before view is ready + private Float pendingScale; + private PointF sPendingCenter; + private PointF sRequestedCenter; + // Source image dimensions and orientation - dimensions relate to the unrotated image + private int sWidth; + private int sHeight; + private int sOrientation; + private Rect sRegion; + private Rect pRegion; + // Is two-finger zooming in progress + private boolean isZooming; + // Is one-finger panning in progress + private boolean isPanning; + // Is quick-scale gesture in progress + private boolean isQuickScaling; + // Max touches used in current gesture + private int maxTouchCount; + // Fling detector + private GestureDetector detector; + private GestureDetector singleDetector; + // Tile and image decoding + private ImageRegionDecoder decoder; + private DecoderFactory<? extends ImageDecoder> bitmapDecoderFactory = new CompatDecoderFactory<ImageDecoder>(SkiaImageDecoder.class); + private DecoderFactory<? extends ImageRegionDecoder> regionDecoderFactory = new CompatDecoderFactory<ImageRegionDecoder>(SkiaImageRegionDecoder.class); + // Debug values + private PointF vCenterStart; + private float vDistStart; + private float quickScaleLastDistance; + private boolean quickScaleMoved; + private PointF quickScaleVLastPoint; + private PointF quickScaleSCenter; + private PointF quickScaleVStart; + // Scale and center animation tracking + private Anim anim; + // Whether a ready notification has been sent to subclasses + private boolean readySent; + // Whether a base layer loaded notification has been sent to subclasses + private boolean imageLoadedSent; + // Event listener + private OnImageEventListener onImageEventListener; + // Scale and center listener + private OnStateChangedListener onStateChangedListener; + // Long click listener + private OnLongClickListener onLongClickListener; + // Paint objects created once and reused for efficiency + private Paint bitmapPaint; + private Paint debugTextPaint; + private Paint debugLinePaint; + private Paint tileBgPaint; + // Volatile fields used to reduce object creation + private ScaleAndTranslate satTemp; + private Matrix matrix; + private RectF sRect; + private boolean atXEdge; + private boolean atYEdge; + public SubsamplingScaleImageView(Context context, AttributeSet attr) { + super(context, attr); + density = getResources().getDisplayMetrics().density; + setMinimumDpi(160); + setDoubleTapZoomDpi(160); + setMinimumTileDpi(320); + setGestureDetector(context); + this.handler = new Handler(new Handler.Callback() { + @Override + public boolean handleMessage(Message message) { + if (message.what == MESSAGE_LONG_CLICK && onLongClickListener != null) { + maxTouchCount = 0; + SubsamplingScaleImageView.super.setOnLongClickListener(onLongClickListener); + performLongClick(); + SubsamplingScaleImageView.super.setOnLongClickListener(null); + } + return true; + } + }); + // Handle XML attributes + if (attr != null) { + TypedArray typedAttr = getContext().obtainStyledAttributes(attr, R.styleable.SubsamplingScaleImageView); + if (typedAttr.hasValue(R.styleable.SubsamplingScaleImageView_assetName)) { + String assetName = typedAttr.getString(R.styleable.SubsamplingScaleImageView_assetName); + if (assetName != null && assetName.length() > 0) { + setImage(ImageSource.asset(assetName).tilingEnabled()); + } + } + if (typedAttr.hasValue(R.styleable.SubsamplingScaleImageView_src)) { + int resId = typedAttr.getResourceId(R.styleable.SubsamplingScaleImageView_src, 0); + if (resId > 0) { + setImage(ImageSource.resource(resId).tilingEnabled()); + } + } + if (typedAttr.hasValue(R.styleable.SubsamplingScaleImageView_panEnabled)) { + setPanEnabled(typedAttr.getBoolean(R.styleable.SubsamplingScaleImageView_panEnabled, true)); + } + if (typedAttr.hasValue(R.styleable.SubsamplingScaleImageView_zoomEnabled)) { + setZoomEnabled(typedAttr.getBoolean(R.styleable.SubsamplingScaleImageView_zoomEnabled, true)); + } + if (typedAttr.hasValue(R.styleable.SubsamplingScaleImageView_quickScaleEnabled)) { + setQuickScaleEnabled(typedAttr.getBoolean(R.styleable.SubsamplingScaleImageView_quickScaleEnabled, true)); + } + if (typedAttr.hasValue(R.styleable.SubsamplingScaleImageView_tileBackgroundColor)) { + setTileBackgroundColor(typedAttr.getColor(R.styleable.SubsamplingScaleImageView_tileBackgroundColor, Color.argb(0, 0, 0, 0))); + } + typedAttr.recycle(); + } + + quickScaleThreshold = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, context.getResources().getDisplayMetrics()); + } + + public SubsamplingScaleImageView(Context context) { + this(context, null); + } + + /** + * Get the current preferred configuration for decoding bitmaps. {@link ImageDecoder} and {@link ImageRegionDecoder} + * instances can read this and use it when decoding images. + * + * @return the preferred bitmap configuration, or null if none has been set. + */ + public static Bitmap.Config getPreferredBitmapConfig() { + return preferredBitmapConfig; + } + + /** + * Set a global preferred bitmap config shared by all view instances and applied to new instances + * initialised after the call is made. This is a hint only; the bundled {@link ImageDecoder} and + * {@link ImageRegionDecoder} classes all respect this (except when they were constructed with + * an instance-specific config) but custom decoder classes will not. + * + * @param preferredBitmapConfig the bitmap configuration to be used by future instances of the view. Pass null to restore the default. + */ + public static void setPreferredBitmapConfig(Bitmap.Config preferredBitmapConfig) { + SubsamplingScaleImageView.preferredBitmapConfig = preferredBitmapConfig; + } + + /** + * Set the image source from a bitmap, resource, asset, file or other URI. + * + * @param imageSource Image source. + */ + public final void setImage(@NonNull ImageSource imageSource) { + setImage(imageSource, null, null); + } + + /** + * Set the image source from a bitmap, resource, asset, file or other URI, starting with a given orientation + * setting, scale and center. This is the best method to use when you want scale and center to be restored + * after screen orientation change; it avoids any redundant loading of tiles in the wrong orientation. + * + * @param imageSource Image source. + * @param state State to be restored. Nullable. + */ + public final void setImage(@NonNull ImageSource imageSource, ImageViewState state) { + setImage(imageSource, null, state); + } + + /** + * Set the image source from a bitmap, resource, asset, file or other URI, providing a preview image to be + * displayed until the full size image is loaded. + * <p> + * You must declare the dimensions of the full size image by calling {@link ImageSource#dimensions(int, int)} + * on the imageSource object. The preview source will be ignored if you don't provide dimensions, + * and if you provide a bitmap for the full size image. + * + * @param imageSource Image source. Dimensions must be declared. + * @param previewSource Optional source for a preview image to be displayed and allow interaction while the full size image loads. + */ + public final void setImage(@NonNull ImageSource imageSource, ImageSource previewSource) { + setImage(imageSource, previewSource, null); + } + + /** + * Set the image source from a bitmap, resource, asset, file or other URI, providing a preview image to be + * displayed until the full size image is loaded, starting with a given orientation setting, scale and center. + * This is the best method to use when you want scale and center to be restored after screen orientation change; + * it avoids any redundant loading of tiles in the wrong orientation. + * <p> + * You must declare the dimensions of the full size image by calling {@link ImageSource#dimensions(int, int)} + * on the imageSource object. The preview source will be ignored if you don't provide dimensions, + * and if you provide a bitmap for the full size image. + * + * @param imageSource Image source. Dimensions must be declared. + * @param previewSource Optional source for a preview image to be displayed and allow interaction while the full size image loads. + * @param state State to be restored. Nullable. + */ + public final void setImage(@NonNull ImageSource imageSource, ImageSource previewSource, ImageViewState state) { + //noinspection ConstantConditions + if (imageSource == null) { + throw new NullPointerException("imageSource must not be null"); + } + + reset(true); + if (state != null) { + restoreState(state); + } + + if (previewSource != null) { + if (imageSource.getBitmap() != null) { + throw new IllegalArgumentException("Preview image cannot be used when a bitmap is provided for the main image"); + } + if (imageSource.getSWidth() <= 0 || imageSource.getSHeight() <= 0) { + throw new IllegalArgumentException("Preview image cannot be used unless dimensions are provided for the main image"); + } + this.sWidth = imageSource.getSWidth(); + this.sHeight = imageSource.getSHeight(); + this.pRegion = previewSource.getSRegion(); + if (previewSource.getBitmap() != null) { + this.bitmapIsCached = previewSource.isCached(); + onPreviewLoaded(previewSource.getBitmap()); + } else { + Uri uri = previewSource.getUri(); + if (uri == null && previewSource.getResource() != null) { + uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + previewSource.getResource()); + } + BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, true); + execute(task); + } + } + + if (imageSource.getBitmap() != null && imageSource.getSRegion() != null) { + onImageLoaded(Bitmap.createBitmap(imageSource.getBitmap(), imageSource.getSRegion().left, imageSource.getSRegion().top, imageSource.getSRegion().width(), imageSource.getSRegion().height()), ORIENTATION_0, false); + } else if (imageSource.getBitmap() != null) { + onImageLoaded(imageSource.getBitmap(), ORIENTATION_0, imageSource.isCached()); + } else { + sRegion = imageSource.getSRegion(); + uri = imageSource.getUri(); + if (uri == null && imageSource.getResource() != null) { + uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + imageSource.getResource()); + } + if (imageSource.getTile() || sRegion != null) { + // Load the bitmap using tile decoding. + TilesInitTask task = new TilesInitTask(this, getContext(), regionDecoderFactory, uri); + execute(task); + } else { + // Load the bitmap as a single image. + BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false); + execute(task); + } + } + } + + /** + * Reset all state before setting/changing image or setting new rotation. + */ + private void reset(boolean newImage) { + debug("reset newImage=" + newImage); + scale = 0f; + scaleStart = 0f; + vTranslate = null; + vTranslateStart = null; + vTranslateBefore = null; + pendingScale = 0f; + sPendingCenter = null; + sRequestedCenter = null; + isZooming = false; + isPanning = false; + isQuickScaling = false; + maxTouchCount = 0; + fullImageSampleSize = 0; + vCenterStart = null; + vDistStart = 0; + quickScaleLastDistance = 0f; + quickScaleMoved = false; + quickScaleSCenter = null; + quickScaleVLastPoint = null; + quickScaleVStart = null; + anim = null; + satTemp = null; + matrix = null; + sRect = null; + if (newImage) { + uri = null; + decoderLock.writeLock().lock(); + try { + if (decoder != null) { + decoder.recycle(); + decoder = null; + } + } finally { + decoderLock.writeLock().unlock(); + } + if (bitmap != null && !bitmapIsCached) { + bitmap.recycle(); + } + if (bitmap != null && bitmapIsCached && onImageEventListener != null) { + onImageEventListener.onPreviewReleased(); + } + sWidth = 0; + sHeight = 0; + sOrientation = 0; + sRegion = null; + pRegion = null; + readySent = false; + imageLoadedSent = false; + bitmap = null; + bitmapIsPreview = false; + bitmapIsCached = false; + } + if (tileMap != null) { + for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { + for (Tile tile : tileMapEntry.getValue()) { + tile.visible = false; + if (tile.bitmap != null) { + tile.bitmap.recycle(); + tile.bitmap = null; + } + } + } + tileMap = null; + } + setGestureDetector(getContext()); + } + + private void setGestureDetector(final Context context) { + this.detector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (panEnabled && readySent && vTranslate != null && e1 != null && e2 != null && (Math.abs(e1.getX() - e2.getX()) > 50 || Math.abs(e1.getY() - e2.getY()) > 50) && (Math.abs(velocityX) > 500 || Math.abs(velocityY) > 500) && !isZooming) { + PointF vTranslateEnd = new PointF(vTranslate.x + (velocityX * 0.25f), vTranslate.y + (velocityY * 0.25f)); + float sCenterXEnd = ((getWidth() / 2) - vTranslateEnd.x) / scale; + float sCenterYEnd = ((getHeight() / 2) - vTranslateEnd.y) / scale; + new AnimationBuilder(new PointF(sCenterXEnd, sCenterYEnd)).withEasing(EASE_OUT_QUAD).withPanLimited(false).withOrigin(ORIGIN_FLING).start(); + return true; + } + return super.onFling(e1, e2, velocityX, velocityY); + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + performClick(); + return true; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + if (zoomEnabled && readySent && vTranslate != null) { + // Hacky solution for #15 - after a double tap the GestureDetector gets in a state + // where the next fling is ignored, so here we replace it with a new one. + setGestureDetector(context); + if (quickScaleEnabled) { + // Store quick scale params. This will become either a double tap zoom or a + // quick scale depending on whether the user swipes. + vCenterStart = new PointF(e.getX(), e.getY()); + vTranslateStart = new PointF(vTranslate.x, vTranslate.y); + scaleStart = scale; + isQuickScaling = true; + isZooming = true; + quickScaleLastDistance = -1F; + quickScaleSCenter = viewToSourceCoord(vCenterStart); + quickScaleVStart = new PointF(e.getX(), e.getY()); + quickScaleVLastPoint = new PointF(quickScaleSCenter.x, quickScaleSCenter.y); + quickScaleMoved = false; + // We need to get events in onTouchEvent after this. + return false; + } else { + // Start double tap zoom animation. + doubleTapZoom(viewToSourceCoord(new PointF(e.getX(), e.getY())), new PointF(e.getX(), e.getY())); + return true; + } + } + return super.onDoubleTapEvent(e); + } + }); + + singleDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + performClick(); + return true; + } + }); + } + + /** + * On resize, preserve center and scale. Various behaviours are possible, override this method to use another. + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + debug("onSizeChanged %dx%d -> %dx%d", oldw, oldh, w, h); + PointF sCenter = getCenter(); + if (readySent && sCenter != null) { + this.anim = null; + this.pendingScale = scale; + this.sPendingCenter = sCenter; + } + } + + /** + * Measures the width and height of the view, preserving the aspect ratio of the image displayed if wrap_content is + * used. The image will scale within this box, not resizing the view as it is zoomed. + */ + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); + int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); + int parentWidth = MeasureSpec.getSize(widthMeasureSpec); + int parentHeight = MeasureSpec.getSize(heightMeasureSpec); + boolean resizeWidth = widthSpecMode != MeasureSpec.EXACTLY; + boolean resizeHeight = heightSpecMode != MeasureSpec.EXACTLY; + int width = parentWidth; + int height = parentHeight; + if (sWidth > 0 && sHeight > 0) { + if (resizeWidth && resizeHeight) { + width = sWidth(); + height = sHeight(); + } else if (resizeHeight) { + height = (int) ((((double) sHeight() / (double) sWidth()) * width)); + } else if (resizeWidth) { + width = (int) ((((double) sWidth() / (double) sHeight()) * height)); + } + } + width = Math.max(width, getSuggestedMinimumWidth()); + height = Math.max(height, getSuggestedMinimumHeight()); + setMeasuredDimension(width, height); + } + + /** + * Handle touch events. One finger pans, and two finger pinch and zoom plus panning. + */ + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + // During non-interruptible anims, ignore all touch events + if (anim != null && !anim.interruptible) { + requestDisallowInterceptTouchEvent(true); + return true; + } else { + if (anim != null && anim.listener != null) { + try { + anim.listener.onInterruptedByUser(); + } catch (Exception e) { + SLog.INSTANCE.w(TAG, "Error thrown by animation listener", e); + } + } + anim = null; + } + + // Abort if not ready + if (vTranslate == null) { + if (singleDetector != null) { + singleDetector.onTouchEvent(event); + } + return true; + } + // Detect flings, taps and double taps + if (!isQuickScaling && (detector == null || detector.onTouchEvent(event))) { + isZooming = false; + isPanning = false; + maxTouchCount = 0; + return true; + } + + if (vTranslateStart == null) { + vTranslateStart = new PointF(0, 0); + } + if (vTranslateBefore == null) { + vTranslateBefore = new PointF(0, 0); + } + if (vCenterStart == null) { + vCenterStart = new PointF(0, 0); + } + + // Store current values so we can send an event if they change + float scaleBefore = scale; + if (vTranslateBefore != null && vTranslate != null) { + vTranslateBefore.set(vTranslate); + } + + boolean handled = onTouchEventInternal(event); + sendStateChanged(scaleBefore, vTranslateBefore, ORIGIN_TOUCH); + return handled || super.onTouchEvent(event); + } + + @SuppressWarnings("deprecation") + private boolean onTouchEventInternal(@NonNull MotionEvent event) { + int touchCount = event.getPointerCount(); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_1_DOWN: + case MotionEvent.ACTION_POINTER_2_DOWN: + anim = null; + requestDisallowInterceptTouchEvent(true); + maxTouchCount = Math.max(maxTouchCount, touchCount); + if (touchCount >= 2) { + if (zoomEnabled) { + // Start pinch to zoom. Calculate distance between touch points and center point of the pinch. + float distance = distance(event.getX(0), event.getX(1), event.getY(0), event.getY(1)); + scaleStart = scale; + vDistStart = distance; + vTranslateStart.set(vTranslate.x, vTranslate.y); + vCenterStart.set((event.getX(0) + event.getX(1)) / 2, (event.getY(0) + event.getY(1)) / 2); + } else { + // Abort all gestures on second touch + maxTouchCount = 0; + } + // Cancel long click timer + handler.removeMessages(MESSAGE_LONG_CLICK); + } else if (!isQuickScaling) { + // Start one-finger pan + vTranslateStart.set(vTranslate.x, vTranslate.y); + vCenterStart.set(event.getX(), event.getY()); + + // Start long click timer + handler.sendEmptyMessageDelayed(MESSAGE_LONG_CLICK, 600); + } + return true; + case MotionEvent.ACTION_MOVE: + boolean consumed = false; + if (maxTouchCount > 0) { + if (touchCount >= 2) { + // Calculate new distance between touch points, to scale and pan relative to start values. + float vDistEnd = distance(event.getX(0), event.getX(1), event.getY(0), event.getY(1)); + float vCenterEndX = (event.getX(0) + event.getX(1)) / 2; + float vCenterEndY = (event.getY(0) + event.getY(1)) / 2; + + if (zoomEnabled && (distance(vCenterStart.x, vCenterEndX, vCenterStart.y, vCenterEndY) > 5 || Math.abs(vDistEnd - vDistStart) > 5 || isPanning)) { + isZooming = true; + isPanning = true; + consumed = true; + + double previousScale = scale; + scale = Math.min(maxScale, (vDistEnd / vDistStart) * scaleStart); + + if (scale <= minScale()) { + // Minimum scale reached so don't pan. Adjust start settings so any expand will zoom in. + vDistStart = vDistEnd; + scaleStart = minScale(); + vCenterStart.set(vCenterEndX, vCenterEndY); + vTranslateStart.set(vTranslate); + } else if (panEnabled) { + // Translate to place the source image coordinate that was at the center of the pinch at the start + // at the center of the pinch now, to give simultaneous pan + zoom. + float vLeftStart = vCenterStart.x - vTranslateStart.x; + float vTopStart = vCenterStart.y - vTranslateStart.y; + float vLeftNow = vLeftStart * (scale / scaleStart); + float vTopNow = vTopStart * (scale / scaleStart); + vTranslate.x = vCenterEndX - vLeftNow; + vTranslate.y = vCenterEndY - vTopNow; + if ((previousScale * sHeight() < getHeight() && scale * sHeight() >= getHeight()) || (previousScale * sWidth() < getWidth() && scale * sWidth() >= getWidth())) { + fitToBounds(true); + vCenterStart.set(vCenterEndX, vCenterEndY); + vTranslateStart.set(vTranslate); + scaleStart = scale; + vDistStart = vDistEnd; + } + } else if (sRequestedCenter != null) { + // With a center specified from code, zoom around that point. + vTranslate.x = (getWidth() / 2) - (scale * sRequestedCenter.x); + vTranslate.y = (getHeight() / 2) - (scale * sRequestedCenter.y); + } else { + // With no requested center, scale around the image center. + vTranslate.x = (getWidth() / 2) - (scale * (sWidth() / 2)); + vTranslate.y = (getHeight() / 2) - (scale * (sHeight() / 2)); + } + + fitToBounds(true); + refreshRequiredTiles(eagerLoadingEnabled); + } + } else if (isQuickScaling) { + // One finger zoom + // Stole Google's Magical Formula鈩� to make sure it feels the exact same + float dist = Math.abs(quickScaleVStart.y - event.getY()) * 2 + quickScaleThreshold; + + if (quickScaleLastDistance == -1f) { + quickScaleLastDistance = dist; + } + boolean isUpwards = event.getY() > quickScaleVLastPoint.y; + quickScaleVLastPoint.set(0, event.getY()); + + float spanDiff = Math.abs(1 - (dist / quickScaleLastDistance)) * 0.5f; + + if (spanDiff > 0.03f || quickScaleMoved) { + quickScaleMoved = true; + + float multiplier = 1; + if (quickScaleLastDistance > 0) { + multiplier = isUpwards ? (1 + spanDiff) : (1 - spanDiff); + } + + double previousScale = scale; + scale = Math.max(minScale(), Math.min(maxScale, scale * multiplier)); + + if (panEnabled) { + float vLeftStart = vCenterStart.x - vTranslateStart.x; + float vTopStart = vCenterStart.y - vTranslateStart.y; + float vLeftNow = vLeftStart * (scale / scaleStart); + float vTopNow = vTopStart * (scale / scaleStart); + vTranslate.x = vCenterStart.x - vLeftNow; + vTranslate.y = vCenterStart.y - vTopNow; + if ((previousScale * sHeight() < getHeight() && scale * sHeight() >= getHeight()) || (previousScale * sWidth() < getWidth() && scale * sWidth() >= getWidth())) { + fitToBounds(true); + vCenterStart.set(sourceToViewCoord(quickScaleSCenter)); + vTranslateStart.set(vTranslate); + scaleStart = scale; + dist = 0; + } + } else if (sRequestedCenter != null) { + // With a center specified from code, zoom around that point. + vTranslate.x = (getWidth() / 2) - (scale * sRequestedCenter.x); + vTranslate.y = (getHeight() / 2) - (scale * sRequestedCenter.y); + } else { + // With no requested center, scale around the image center. + vTranslate.x = (getWidth() / 2) - (scale * (sWidth() / 2)); + vTranslate.y = (getHeight() / 2) - (scale * (sHeight() / 2)); + } + } + + quickScaleLastDistance = dist; + + fitToBounds(true); + refreshRequiredTiles(eagerLoadingEnabled); + + consumed = true; + } else if (!isZooming) { + // One finger pan - translate the image. We do this calculation even with pan disabled so click + // and long click behaviour is preserved. + float dx = Math.abs(event.getX() - vCenterStart.x); + float dy = Math.abs(event.getY() - vCenterStart.y); + + //On the Samsung S6 long click event does not work, because the dx > 5 usually true + float offset = density * 5; + if (dx > offset || dy > offset || isPanning) { + consumed = true; + vTranslate.x = vTranslateStart.x + (event.getX() - vCenterStart.x); + vTranslate.y = vTranslateStart.y + (event.getY() - vCenterStart.y); + + float lastX = vTranslate.x; + float lastY = vTranslate.y; + fitToBounds(true); + atXEdge = lastX != vTranslate.x; + atYEdge = lastY != vTranslate.y; + boolean edgeXSwipe = atXEdge && dx > dy && !isPanning; + boolean edgeYSwipe = atYEdge && dy > dx && !isPanning; + boolean yPan = lastY == vTranslate.y && dy > offset * 3; + if (!edgeXSwipe && !edgeYSwipe && (!atXEdge || !atYEdge || yPan || isPanning)) { + isPanning = true; + } else if (dx > offset || dy > offset) { + // Haven't panned the image, and we're at the left or right edge. Switch to page swipe. + maxTouchCount = 0; + handler.removeMessages(MESSAGE_LONG_CLICK); + requestDisallowInterceptTouchEvent(false); + } + if (!panEnabled) { + vTranslate.x = vTranslateStart.x; + vTranslate.y = vTranslateStart.y; + requestDisallowInterceptTouchEvent(false); + } + + refreshRequiredTiles(eagerLoadingEnabled); + } + } + } + if (consumed) { + handler.removeMessages(MESSAGE_LONG_CLICK); + invalidate(); + return true; + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_POINTER_2_UP: + handler.removeMessages(MESSAGE_LONG_CLICK); + if (isQuickScaling) { + isQuickScaling = false; + if (!quickScaleMoved) { + doubleTapZoom(quickScaleSCenter, vCenterStart); + } + } + if (maxTouchCount > 0 && (isZooming || isPanning)) { + if (isZooming && touchCount == 2) { + // Convert from zoom to pan with remaining touch + isPanning = true; + vTranslateStart.set(vTranslate.x, vTranslate.y); + if (event.getActionIndex() == 1) { + vCenterStart.set(event.getX(0), event.getY(0)); + } else { + vCenterStart.set(event.getX(1), event.getY(1)); + } + } + if (touchCount < 3) { + // End zooming when only one touch point + isZooming = false; + } + if (touchCount < 2) { + // End panning when no touch points + isPanning = false; + maxTouchCount = 0; + } + // Trigger load of tiles now required + refreshRequiredTiles(true); + return true; + } + if (touchCount == 1) { + isZooming = false; + isPanning = false; + maxTouchCount = 0; + } + return true; + } + return false; + } + + private void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { + ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(disallowIntercept); + } + } + + /** + * Double tap zoom handler triggered from gesture detector or on touch, depending on whether + * quick scale is enabled. + */ + private void doubleTapZoom(PointF sCenter, PointF vFocus) { + if (!panEnabled) { + if (sRequestedCenter != null) { + // With a center specified from code, zoom around that point. + sCenter.x = sRequestedCenter.x; + sCenter.y = sRequestedCenter.y; + } else { + // With no requested center, scale around the image center. + sCenter.x = sWidth() / 2; + sCenter.y = sHeight() / 2; + } + } + float doubleTapZoomScale = Math.min(maxScale, SubsamplingScaleImageView.this.doubleTapZoomScale); + boolean zoomIn = (scale <= doubleTapZoomScale * 0.9) || scale == minScale; + float targetScale = zoomIn ? doubleTapZoomScale : minScale(); + if (doubleTapZoomStyle == ZOOM_FOCUS_CENTER_IMMEDIATE) { + setScaleAndCenter(targetScale, sCenter); + } else if (doubleTapZoomStyle == ZOOM_FOCUS_CENTER || !zoomIn || !panEnabled) { + new AnimationBuilder(targetScale, sCenter).withInterruptible(false).withDuration(doubleTapZoomDuration).withOrigin(ORIGIN_DOUBLE_TAP_ZOOM).start(); + } else if (doubleTapZoomStyle == ZOOM_FOCUS_FIXED) { + new AnimationBuilder(targetScale, sCenter, vFocus).withInterruptible(false).withDuration(doubleTapZoomDuration).withOrigin(ORIGIN_DOUBLE_TAP_ZOOM).start(); + } + invalidate(); + } + + /** + * Draw method should not be called until the view has dimensions so the first calls are used as triggers to calculate + * the scaling and tiling required. Once the view is setup, tiles are displayed as they are loaded. + */ + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + createPaints(); + + // If image or view dimensions are not known yet, abort. + if (sWidth == 0 || sHeight == 0 || getWidth() == 0 || getHeight() == 0) { + return; + } + + // When using tiles, on first render with no tile map ready, initialise it and kick off async base image loading. + if (tileMap == null && decoder != null) { + initialiseBaseLayer(getMaxBitmapDimensions(canvas)); + } + + // If image has been loaded or supplied as a bitmap, onDraw may be the first time the view has + // dimensions and therefore the first opportunity to set scale and translate. If this call returns + // false there is nothing to be drawn so return immediately. + if (!checkReady()) { + return; + } + + // Set scale and translate before draw. + preDraw(); + + // If animating scale, calculate current scale and center with easing equations + if (anim != null && anim.vFocusStart != null) { + // Store current values so we can send an event if they change + float scaleBefore = scale; + if (vTranslateBefore == null) { + vTranslateBefore = new PointF(0, 0); + } + vTranslateBefore.set(vTranslate); + + long scaleElapsed = System.currentTimeMillis() - anim.time; + boolean finished = scaleElapsed > anim.duration; + scaleElapsed = Math.min(scaleElapsed, anim.duration); + scale = ease(anim.easing, scaleElapsed, anim.scaleStart, anim.scaleEnd - anim.scaleStart, anim.duration); + + // Apply required animation to the focal point + float vFocusNowX = ease(anim.easing, scaleElapsed, anim.vFocusStart.x, anim.vFocusEnd.x - anim.vFocusStart.x, anim.duration); + float vFocusNowY = ease(anim.easing, scaleElapsed, anim.vFocusStart.y, anim.vFocusEnd.y - anim.vFocusStart.y, anim.duration); + // Find out where the focal point is at this scale and adjust its position to follow the animation path + vTranslate.x -= sourceToViewX(anim.sCenterEnd.x) - vFocusNowX; + vTranslate.y -= sourceToViewY(anim.sCenterEnd.y) - vFocusNowY; + + // For translate anims, showing the image non-centered is never allowed, for scaling anims it is during the animation. + fitToBounds(finished || (anim.scaleStart == anim.scaleEnd)); + sendStateChanged(scaleBefore, vTranslateBefore, anim.origin); + refreshRequiredTiles(finished); + if (finished) { + if (anim.listener != null) { + try { + anim.listener.onComplete(); + } catch (Exception e) { + SLog.INSTANCE.w(TAG, "Error thrown by animation listener", e); + } + } + anim = null; + } + invalidate(); + } + + if (tileMap != null && isBaseLayerReady()) { + + // Optimum sample size for current scale + int sampleSize = Math.min(fullImageSampleSize, calculateInSampleSize(scale)); + + // First check for missing tiles - if there are any we need the base layer underneath to avoid gaps + boolean hasMissingTiles = false; + for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { + if (tileMapEntry.getKey() == sampleSize) { + for (Tile tile : tileMapEntry.getValue()) { + if (tile.visible && (tile.loading || tile.bitmap == null)) { + hasMissingTiles = true; + } + } + } + } + + // Render all loaded tiles. LinkedHashMap used for bottom up rendering - lower res tiles underneath. + for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { + if (tileMapEntry.getKey() == sampleSize || hasMissingTiles) { + for (Tile tile : tileMapEntry.getValue()) { + sourceToViewRect(tile.sRect, tile.vRect); + if (!tile.loading && tile.bitmap != null) { + if (tileBgPaint != null) { + canvas.drawRect(tile.vRect, tileBgPaint); + } + if (matrix == null) { + matrix = new Matrix(); + } + matrix.reset(); + setMatrixArray(srcArray, 0, 0, tile.bitmap.getWidth(), 0, tile.bitmap.getWidth(), tile.bitmap.getHeight(), 0, tile.bitmap.getHeight()); + if (getRequiredRotation() == ORIENTATION_0) { + setMatrixArray(dstArray, tile.vRect.left, tile.vRect.top, tile.vRect.right, tile.vRect.top, tile.vRect.right, tile.vRect.bottom, tile.vRect.left, tile.vRect.bottom); + } else if (getRequiredRotation() == ORIENTATION_90) { + setMatrixArray(dstArray, tile.vRect.right, tile.vRect.top, tile.vRect.right, tile.vRect.bottom, tile.vRect.left, tile.vRect.bottom, tile.vRect.left, tile.vRect.top); + } else if (getRequiredRotation() == ORIENTATION_180) { + setMatrixArray(dstArray, tile.vRect.right, tile.vRect.bottom, tile.vRect.left, tile.vRect.bottom, tile.vRect.left, tile.vRect.top, tile.vRect.right, tile.vRect.top); + } else if (getRequiredRotation() == ORIENTATION_270) { + setMatrixArray(dstArray, tile.vRect.left, tile.vRect.bottom, tile.vRect.left, tile.vRect.top, tile.vRect.right, tile.vRect.top, tile.vRect.right, tile.vRect.bottom); + } + matrix.setPolyToPoly(srcArray, 0, dstArray, 0, 4); + canvas.drawBitmap(tile.bitmap, matrix, bitmapPaint); + if (debug) { + canvas.drawRect(tile.vRect, debugLinePaint); + } + } else if (tile.loading && debug) { + canvas.drawText("LOADING", tile.vRect.left + px(5), tile.vRect.top + px(35), debugTextPaint); + } + if (tile.visible && debug) { + canvas.drawText("ISS " + tile.sampleSize + " RECT " + tile.sRect.top + "," + tile.sRect.left + "," + tile.sRect.bottom + "," + tile.sRect.right, tile.vRect.left + px(5), tile.vRect.top + px(15), debugTextPaint); + } + } + } + } + + } else if (bitmap != null && !bitmap.isRecycled()) { + + float xScale = scale, yScale = scale; + if (bitmapIsPreview) { + xScale = scale * ((float) sWidth / bitmap.getWidth()); + yScale = scale * ((float) sHeight / bitmap.getHeight()); + } + + if (matrix == null) { + matrix = new Matrix(); + } + matrix.reset(); + matrix.postScale(xScale, yScale); + matrix.postRotate(getRequiredRotation()); + matrix.postTranslate(vTranslate.x, vTranslate.y); + + if (getRequiredRotation() == ORIENTATION_180) { + matrix.postTranslate(scale * sWidth, scale * sHeight); + } else if (getRequiredRotation() == ORIENTATION_90) { + matrix.postTranslate(scale * sHeight, 0); + } else if (getRequiredRotation() == ORIENTATION_270) { + matrix.postTranslate(0, scale * sWidth); + } + + if (tileBgPaint != null) { + if (sRect == null) { + sRect = new RectF(); + } + sRect.set(0f, 0f, bitmapIsPreview ? bitmap.getWidth() : sWidth, bitmapIsPreview ? bitmap.getHeight() : sHeight); + matrix.mapRect(sRect); + canvas.drawRect(sRect, tileBgPaint); + } + canvas.drawBitmap(bitmap, matrix, bitmapPaint); + + } + + if (debug) { + canvas.drawText("Scale: " + String.format(Locale.ENGLISH, "%.2f", scale) + " (" + String.format(Locale.ENGLISH, "%.2f", minScale()) + " - " + String.format(Locale.ENGLISH, "%.2f", maxScale) + ")", px(5), px(15), debugTextPaint); + canvas.drawText("Translate: " + String.format(Locale.ENGLISH, "%.2f", vTranslate.x) + ":" + String.format(Locale.ENGLISH, "%.2f", vTranslate.y), px(5), px(30), debugTextPaint); + PointF center = getCenter(); + //noinspection ConstantConditions + canvas.drawText("Source center: " + String.format(Locale.ENGLISH, "%.2f", center.x) + ":" + String.format(Locale.ENGLISH, "%.2f", center.y), px(5), px(45), debugTextPaint); + if (anim != null) { + PointF vCenterStart = sourceToViewCoord(anim.sCenterStart); + PointF vCenterEndRequested = sourceToViewCoord(anim.sCenterEndRequested); + PointF vCenterEnd = sourceToViewCoord(anim.sCenterEnd); + //noinspection ConstantConditions + canvas.drawCircle(vCenterStart.x, vCenterStart.y, px(10), debugLinePaint); + debugLinePaint.setColor(Color.RED); + //noinspection ConstantConditions + canvas.drawCircle(vCenterEndRequested.x, vCenterEndRequested.y, px(20), debugLinePaint); + debugLinePaint.setColor(Color.BLUE); + //noinspection ConstantConditions + canvas.drawCircle(vCenterEnd.x, vCenterEnd.y, px(25), debugLinePaint); + debugLinePaint.setColor(Color.CYAN); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, px(30), debugLinePaint); + } + if (vCenterStart != null) { + debugLinePaint.setColor(Color.RED); + canvas.drawCircle(vCenterStart.x, vCenterStart.y, px(20), debugLinePaint); + } + if (quickScaleSCenter != null) { + debugLinePaint.setColor(Color.BLUE); + canvas.drawCircle(sourceToViewX(quickScaleSCenter.x), sourceToViewY(quickScaleSCenter.y), px(35), debugLinePaint); + } + if (quickScaleVStart != null && isQuickScaling) { + debugLinePaint.setColor(Color.CYAN); + canvas.drawCircle(quickScaleVStart.x, quickScaleVStart.y, px(30), debugLinePaint); + } + debugLinePaint.setColor(Color.MAGENTA); + } + } + + /** + * Helper method for setting the values of a tile matrix array. + */ + private void setMatrixArray(float[] array, float f0, float f1, float f2, float f3, float f4, float f5, float f6, float f7) { + array[0] = f0; + array[1] = f1; + array[2] = f2; + array[3] = f3; + array[4] = f4; + array[5] = f5; + array[6] = f6; + array[7] = f7; + } + + /** + * Checks whether the base layer of tiles or full size bitmap is ready. + */ + private boolean isBaseLayerReady() { + if (bitmap != null && !bitmapIsPreview) { + return true; + } else if (tileMap != null) { + boolean baseLayerReady = true; + for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { + if (tileMapEntry.getKey() == fullImageSampleSize) { + for (Tile tile : tileMapEntry.getValue()) { + if (tile.loading || tile.bitmap == null) { + baseLayerReady = false; + } + } + } + } + return baseLayerReady; + } + return false; + } + + /** + * Check whether view and image dimensions are known and either a preview, full size image or + * base layer tiles are loaded. First time, send ready event to listener. The next draw will + * display an image. + */ + private boolean checkReady() { + boolean ready = getWidth() > 0 && getHeight() > 0 && sWidth > 0 && sHeight > 0 && (bitmap != null || isBaseLayerReady()); + if (!readySent && ready) { + preDraw(); + readySent = true; + onReady(); + if (onImageEventListener != null) { + onImageEventListener.onReady(); + } + } + return ready; + } + + /** + * Check whether either the full size bitmap or base layer tiles are loaded. First time, send image + * loaded event to listener. + */ + private boolean checkImageLoaded() { + boolean imageLoaded = isBaseLayerReady(); + if (!imageLoadedSent && imageLoaded) { + preDraw(); + imageLoadedSent = true; + onImageLoaded(); + if (onImageEventListener != null) { + onImageEventListener.onImageLoaded(); + } + } + return imageLoaded; + } + + /** + * Creates Paint objects once when first needed. + */ + private void createPaints() { + if (bitmapPaint == null) { + bitmapPaint = new Paint(); + bitmapPaint.setAntiAlias(true); + bitmapPaint.setFilterBitmap(true); + bitmapPaint.setDither(true); + } + if ((debugTextPaint == null || debugLinePaint == null) && debug) { + debugTextPaint = new Paint(); + debugTextPaint.setTextSize(px(12)); + debugTextPaint.setColor(Color.MAGENTA); + debugTextPaint.setStyle(Style.FILL); + debugLinePaint = new Paint(); + debugLinePaint.setColor(Color.MAGENTA); + debugLinePaint.setStyle(Style.STROKE); + debugLinePaint.setStrokeWidth(px(1)); + } + } + + /** + * Called on first draw when the view has dimensions. Calculates the initial sample size and starts async loading of + * the base layer image - the whole source subsampled as necessary. + */ + private synchronized void initialiseBaseLayer(@NonNull Point maxTileDimensions) { + debug("initialiseBaseLayer maxTileDimensions=%dx%d", maxTileDimensions.x, maxTileDimensions.y); + + satTemp = new ScaleAndTranslate(0f, new PointF(0, 0)); + fitToBounds(true, satTemp); + + // Load double resolution - next level will be split into four tiles and at the center all four are required, + // so don't bother with tiling until the next level 16 tiles are needed. + fullImageSampleSize = calculateInSampleSize(satTemp.scale); + if (fullImageSampleSize > 1) { + fullImageSampleSize /= 2; + } + + if (fullImageSampleSize == 1 && sRegion == null && sWidth() < maxTileDimensions.x && sHeight() < maxTileDimensions.y) { + + // Whole image is required at native resolution, and is smaller than the canvas max bitmap size. + // Use BitmapDecoder for better image support. + decoder.recycle(); + decoder = null; + BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false); + execute(task); + + } else { + + initialiseTileMap(maxTileDimensions); + + List<Tile> baseGrid = tileMap.get(fullImageSampleSize); + for (Tile baseTile : baseGrid) { + TileLoadTask task = new TileLoadTask(this, decoder, baseTile); + execute(task); + } + refreshRequiredTiles(true); + + } + + } + + /** + * Loads the optimum tiles for display at the current scale and translate, so the screen can be filled with tiles + * that are at least as high resolution as the screen. Frees up bitmaps that are now off the screen. + * + * @param load Whether to load the new tiles needed. Use false while scrolling/panning for performance. + */ + private void refreshRequiredTiles(boolean load) { + if (decoder == null || tileMap == null) { + return; + } + + int sampleSize = Math.min(fullImageSampleSize, calculateInSampleSize(scale)); + + // Load tiles of the correct sample size that are on screen. Discard tiles off screen, and those that are higher + // resolution than required, or lower res than required but not the base layer, so the base layer is always present. + for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { + for (Tile tile : tileMapEntry.getValue()) { + if (tile.sampleSize < sampleSize || (tile.sampleSize > sampleSize && tile.sampleSize != fullImageSampleSize)) { + tile.visible = false; + if (tile.bitmap != null) { + tile.bitmap.recycle(); + tile.bitmap = null; + } + } + if (tile.sampleSize == sampleSize) { + if (tileVisible(tile)) { + tile.visible = true; + if (!tile.loading && tile.bitmap == null && load) { + TileLoadTask task = new TileLoadTask(this, decoder, tile); + execute(task); + } + } else if (tile.sampleSize != fullImageSampleSize) { + tile.visible = false; + if (tile.bitmap != null) { + tile.bitmap.recycle(); + tile.bitmap = null; + } + } + } else if (tile.sampleSize == fullImageSampleSize) { + tile.visible = true; + } + } + } + + } + + /** + * Determine whether tile is visible. + */ + private boolean tileVisible(Tile tile) { + float sVisLeft = viewToSourceX(0), + sVisRight = viewToSourceX(getWidth()), + sVisTop = viewToSourceY(0), + sVisBottom = viewToSourceY(getHeight()); + return !(sVisLeft > tile.sRect.right || tile.sRect.left > sVisRight || sVisTop > tile.sRect.bottom || tile.sRect.top > sVisBottom); + } + + /** + * Sets scale and translate ready for the next draw. + */ + private void preDraw() { + if (getWidth() == 0 || getHeight() == 0 || sWidth <= 0 || sHeight <= 0) { + return; + } + + // If waiting to translate to new center position, set translate now + if (sPendingCenter != null && pendingScale != null) { + scale = pendingScale; + if (vTranslate == null) { + vTranslate = new PointF(); + } + vTranslate.x = (getWidth() / 2) - (scale * sPendingCenter.x); + vTranslate.y = (getHeight() / 2) - (scale * sPendingCenter.y); + sPendingCenter = null; + pendingScale = null; + fitToBounds(true); + refreshRequiredTiles(true); + } + + // On first display of base image set up position, and in other cases make sure scale is correct. + fitToBounds(false); + } + + /** + * Calculates sample size to fit the source image in given bounds. + */ + private int calculateInSampleSize(float scale) { + if (minimumTileDpi > 0) { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + float averageDpi = (metrics.xdpi + metrics.ydpi) / 2; + scale = (minimumTileDpi / averageDpi) * scale; + } + + int reqWidth = (int) (sWidth() * scale); + int reqHeight = (int) (sHeight() * scale); + + // Raw height and width of image + int inSampleSize = 1; + if (reqWidth == 0 || reqHeight == 0) { + return 32; + } + + if (sHeight() > reqHeight || sWidth() > reqWidth) { + + // Calculate ratios of height and width to requested height and width + final int heightRatio = Math.round((float) sHeight() / (float) reqHeight); + final int widthRatio = Math.round((float) sWidth() / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + // We want the actual sample size that will be used, so round down to nearest power of 2. + int power = 1; + while (power * 2 < inSampleSize) { + power = power * 2; + } + + return power; + } + + /** + * Adjusts hypothetical future scale and translate values to keep scale within the allowed range and the image on screen. Minimum scale + * is set so one dimension fills the view and the image is centered on the other dimension. Used to calculate what the target of an + * animation should be. + * + * @param center Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached. + * @param sat The scale we want and the translation we're aiming for. The values are adjusted to be valid. + */ + private void fitToBounds(boolean center, ScaleAndTranslate sat) { + if (panLimit == PAN_LIMIT_OUTSIDE && isReady()) { + center = false; + } + + PointF vTranslate = sat.vTranslate; + float scale = limitedScale(sat.scale); + float scaleWidth = scale * sWidth(); + float scaleHeight = scale * sHeight(); + + if (panLimit == PAN_LIMIT_CENTER && isReady()) { + vTranslate.x = Math.max(vTranslate.x, getWidth() / 2 - scaleWidth); + vTranslate.y = Math.max(vTranslate.y, getHeight() / 2 - scaleHeight); + } else if (center) { + vTranslate.x = Math.max(vTranslate.x, getWidth() - scaleWidth); + vTranslate.y = Math.max(vTranslate.y, getHeight() - scaleHeight); + } else { + vTranslate.x = Math.max(vTranslate.x, -scaleWidth); + vTranslate.y = Math.max(vTranslate.y, -scaleHeight); + } + + // Asymmetric padding adjustments + float xPaddingRatio = getPaddingLeft() > 0 || getPaddingRight() > 0 ? getPaddingLeft() / (float) (getPaddingLeft() + getPaddingRight()) : 0.5f; + float yPaddingRatio = getPaddingTop() > 0 || getPaddingBottom() > 0 ? getPaddingTop() / (float) (getPaddingTop() + getPaddingBottom()) : 0.5f; + + float maxTx; + float maxTy; + if (panLimit == PAN_LIMIT_CENTER && isReady()) { + maxTx = Math.max(0, getWidth() / 2); + maxTy = Math.max(0, getHeight() / 2); + } else if (center) { + maxTx = Math.max(0, (getWidth() - scaleWidth) * xPaddingRatio); + maxTy = Math.max(0, (getHeight() - scaleHeight) * yPaddingRatio); + } else { + maxTx = Math.max(0, getWidth()); + maxTy = Math.max(0, getHeight()); + } + + vTranslate.x = Math.min(vTranslate.x, maxTx); + vTranslate.y = Math.min(vTranslate.y, maxTy); + + sat.scale = scale; + } + + /** + * Adjusts current scale and translate values to keep scale within the allowed range and the image on screen. Minimum scale + * is set so one dimension fills the view and the image is centered on the other dimension. + * + * @param center Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached. + */ + private void fitToBounds(boolean center) { + boolean init = false; + if (vTranslate == null) { + init = true; + vTranslate = new PointF(0, 0); + } + if (satTemp == null) { + satTemp = new ScaleAndTranslate(0, new PointF(0, 0)); + } + satTemp.scale = scale; + satTemp.vTranslate.set(vTranslate); + fitToBounds(center, satTemp); + scale = satTemp.scale; + vTranslate.set(satTemp.vTranslate); + if (init && minimumScaleType != SCALE_TYPE_START) { + vTranslate.set(vTranslateForSCenter(sWidth() / 2, sHeight() / 2, scale)); + } + } + + /** + * Once source image and view dimensions are known, creates a map of sample size to tile grid. + */ + private void initialiseTileMap(Point maxTileDimensions) { + debug("initialiseTileMap maxTileDimensions=%dx%d", maxTileDimensions.x, maxTileDimensions.y); + this.tileMap = new LinkedHashMap<>(); + int sampleSize = fullImageSampleSize; + int xTiles = 1; + int yTiles = 1; + while (true) { + int sTileWidth = sWidth() / xTiles; + int sTileHeight = sHeight() / yTiles; + int subTileWidth = sTileWidth / sampleSize; + int subTileHeight = sTileHeight / sampleSize; + while (subTileWidth + xTiles + 1 > maxTileDimensions.x || (subTileWidth > getWidth() * 1.25 && sampleSize < fullImageSampleSize)) { + xTiles += 1; + sTileWidth = sWidth() / xTiles; + subTileWidth = sTileWidth / sampleSize; + } + while (subTileHeight + yTiles + 1 > maxTileDimensions.y || (subTileHeight > getHeight() * 1.25 && sampleSize < fullImageSampleSize)) { + yTiles += 1; + sTileHeight = sHeight() / yTiles; + subTileHeight = sTileHeight / sampleSize; + } + List<Tile> tileGrid = new ArrayList<>(xTiles * yTiles); + for (int x = 0; x < xTiles; x++) { + for (int y = 0; y < yTiles; y++) { + Tile tile = new Tile(); + tile.sampleSize = sampleSize; + tile.visible = sampleSize == fullImageSampleSize; + tile.sRect = new Rect( + x * sTileWidth, + y * sTileHeight, + x == xTiles - 1 ? sWidth() : (x + 1) * sTileWidth, + y == yTiles - 1 ? sHeight() : (y + 1) * sTileHeight + ); + tile.vRect = new Rect(0, 0, 0, 0); + tile.fileSRect = new Rect(tile.sRect); + tileGrid.add(tile); + } + } + tileMap.put(sampleSize, tileGrid); + if (sampleSize == 1) { + break; + } else { + sampleSize /= 2; + } + } + } + + /** + * Called by worker task when decoder is ready and image size and EXIF orientation is known. + */ + private synchronized void onTilesInited(ImageRegionDecoder decoder, int sWidth, int sHeight, int sOrientation) { + debug("onTilesInited sWidth=%d, sHeight=%d, sOrientation=%d", sWidth, sHeight, orientation); + // If actual dimensions don't match the declared size, reset everything. + if (this.sWidth > 0 && this.sHeight > 0 && (this.sWidth != sWidth || this.sHeight != sHeight)) { + reset(false); + if (bitmap != null) { + if (!bitmapIsCached) { + bitmap.recycle(); + } + bitmap = null; + if (onImageEventListener != null && bitmapIsCached) { + onImageEventListener.onPreviewReleased(); + } + bitmapIsPreview = false; + bitmapIsCached = false; + } + } + this.decoder = decoder; + this.sWidth = sWidth; + this.sHeight = sHeight; + this.sOrientation = sOrientation; + checkReady(); + if (!checkImageLoaded() && maxTileWidth > 0 && maxTileWidth != TILE_SIZE_AUTO && maxTileHeight > 0 && maxTileHeight != TILE_SIZE_AUTO && getWidth() > 0 && getHeight() > 0) { + initialiseBaseLayer(new Point(maxTileWidth, maxTileHeight)); + } + invalidate(); + requestLayout(); + } + + /** + * Called by worker task when a tile has loaded. Redraws the view. + */ + private synchronized void onTileLoaded() { + debug("onTileLoaded"); + checkReady(); + checkImageLoaded(); + if (isBaseLayerReady() && bitmap != null) { + if (!bitmapIsCached) { + bitmap.recycle(); + } + bitmap = null; + if (onImageEventListener != null && bitmapIsCached) { + onImageEventListener.onPreviewReleased(); + } + bitmapIsPreview = false; + bitmapIsCached = false; + } + invalidate(); + } + + /** + * Called by worker task when preview image is loaded. + */ + private synchronized void onPreviewLoaded(Bitmap previewBitmap) { + debug("onPreviewLoaded"); + if (bitmap != null || imageLoadedSent) { + previewBitmap.recycle(); + return; + } + if (pRegion != null) { + bitmap = Bitmap.createBitmap(previewBitmap, pRegion.left, pRegion.top, pRegion.width(), pRegion.height()); + } else { + bitmap = previewBitmap; + } + bitmapIsPreview = true; + if (checkReady()) { + invalidate(); + requestLayout(); + } + } + + /** + * Called by worker task when full size image bitmap is ready (tiling is disabled). + */ + private synchronized void onImageLoaded(Bitmap bitmap, int sOrientation, boolean bitmapIsCached) { + debug("onImageLoaded"); + // If actual dimensions don't match the declared size, reset everything. + if (this.sWidth > 0 && this.sHeight > 0 && (this.sWidth != bitmap.getWidth() || this.sHeight != bitmap.getHeight())) { + reset(false); + } + if (this.bitmap != null && !this.bitmapIsCached) { + this.bitmap.recycle(); + } + + if (this.bitmap != null && this.bitmapIsCached && onImageEventListener != null) { + onImageEventListener.onPreviewReleased(); + } + + this.bitmapIsPreview = false; + this.bitmapIsCached = bitmapIsCached; + this.bitmap = bitmap; + this.sWidth = bitmap.getWidth(); + this.sHeight = bitmap.getHeight(); + this.sOrientation = sOrientation; + boolean ready = checkReady(); + boolean imageLoaded = checkImageLoaded(); + if (ready || imageLoaded) { + invalidate(); + requestLayout(); + } + } + + /** + * Helper method for load tasks. Examines the EXIF info on the image file to determine the orientation. + * This will only work for external files, not assets, resources or other URIs. + */ + @AnyThread + private int getExifOrientation(Context context, String sourceUri) { + int exifOrientation = ORIENTATION_0; + if (sourceUri.startsWith(ContentResolver.SCHEME_CONTENT)) { + Cursor cursor = null; + try { + String[] columns = {MediaStore.Images.Media.ORIENTATION}; + cursor = context.getContentResolver().query(Uri.parse(sourceUri), columns, null, null, null); + if (cursor != null) { + if (cursor.moveToFirst()) { + int orientation = cursor.getInt(0); + if (VALID_ORIENTATIONS.contains(orientation) && orientation != ORIENTATION_USE_EXIF) { + exifOrientation = orientation; + } else { + SLog.INSTANCE.w(TAG, "Unsupported orientation: " + orientation); + } + } + } + } catch (Exception e) { + SLog.INSTANCE.w(TAG, "Could not get orientation of image from media store"); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } else if (sourceUri.startsWith(ImageSource.FILE_SCHEME) && !sourceUri.startsWith(ImageSource.ASSET_SCHEME)) { + try { + ExifInterface exifInterface = new ExifInterface(sourceUri.substring(ImageSource.FILE_SCHEME.length() - 1)); + int orientationAttr = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + if (orientationAttr == ExifInterface.ORIENTATION_NORMAL || orientationAttr == ExifInterface.ORIENTATION_UNDEFINED) { + exifOrientation = ORIENTATION_0; + } else if (orientationAttr == ExifInterface.ORIENTATION_ROTATE_90) { + exifOrientation = ORIENTATION_90; + } else if (orientationAttr == ExifInterface.ORIENTATION_ROTATE_180) { + exifOrientation = ORIENTATION_180; + } else if (orientationAttr == ExifInterface.ORIENTATION_ROTATE_270) { + exifOrientation = ORIENTATION_270; + } else { + SLog.INSTANCE.w(TAG, "Unsupported EXIF orientation: " + orientationAttr); + } + } catch (Exception e) { + SLog.INSTANCE.w(TAG, "Could not get EXIF orientation of image"); + } + } + return exifOrientation; + } + + private void execute(AsyncTask<Void, Void, ?> asyncTask) { + asyncTask.executeOnExecutor(executor); + } + + /** + * Set scale, center and orientation from saved state. + */ + private void restoreState(ImageViewState state) { + if (state != null && VALID_ORIENTATIONS.contains(state.getOrientation())) { + this.orientation = state.getOrientation(); + this.pendingScale = state.getScale(); + this.sPendingCenter = state.getCenter(); + invalidate(); + } + } + + /** + * By default the View automatically calculates the optimal tile size. Set this to override this, and force an upper limit to the dimensions of the generated tiles. Passing {@link #TILE_SIZE_AUTO} will re-enable the default behaviour. + * + * @param maxPixels Maximum tile size X and Y in pixels. + */ + public void setMaxTileSize(int maxPixels) { + this.maxTileWidth = maxPixels; + this.maxTileHeight = maxPixels; + } + + /** + * By default the View automatically calculates the optimal tile size. Set this to override this, and force an upper limit to the dimensions of the generated tiles. Passing {@link #TILE_SIZE_AUTO} will re-enable the default behaviour. + * + * @param maxPixelsX Maximum tile width. + * @param maxPixelsY Maximum tile height. + */ + public void setMaxTileSize(int maxPixelsX, int maxPixelsY) { + this.maxTileWidth = maxPixelsX; + this.maxTileHeight = maxPixelsY; + } + + /** + * Use canvas max bitmap width and height instead of the default 2048, to avoid redundant tiling. + */ + @NonNull + private Point getMaxBitmapDimensions(Canvas canvas) { + return new Point(Math.min(canvas.getMaximumBitmapWidth(), maxTileWidth), Math.min(canvas.getMaximumBitmapHeight(), maxTileHeight)); + } + + /** + * Get source width taking rotation into account. + */ + @SuppressWarnings("SuspiciousNameCombination") + private int sWidth() { + int rotation = getRequiredRotation(); + if (rotation == 90 || rotation == 270) { + return sHeight; + } else { + return sWidth; + } + } + + /** + * Get source height taking rotation into account. + */ + @SuppressWarnings("SuspiciousNameCombination") + private int sHeight() { + int rotation = getRequiredRotation(); + if (rotation == 90 || rotation == 270) { + return sWidth; + } else { + return sHeight; + } + } + + /** + * Converts source rectangle from tile, which treats the image file as if it were in the correct orientation already, + * to the rectangle of the image that needs to be loaded. + */ + @SuppressWarnings("SuspiciousNameCombination") + @AnyThread + private void fileSRect(Rect sRect, Rect target) { + if (getRequiredRotation() == 0) { + target.set(sRect); + } else if (getRequiredRotation() == 90) { + target.set(sRect.top, sHeight - sRect.right, sRect.bottom, sHeight - sRect.left); + } else if (getRequiredRotation() == 180) { + target.set(sWidth - sRect.right, sHeight - sRect.bottom, sWidth - sRect.left, sHeight - sRect.top); + } else { + target.set(sWidth - sRect.bottom, sRect.left, sWidth - sRect.top, sRect.right); + } + } + + /** + * Determines the rotation to be applied to tiles, based on EXIF orientation or chosen setting. + */ + @AnyThread + private int getRequiredRotation() { + if (orientation == ORIENTATION_USE_EXIF) { + return sOrientation; + } else { + return orientation; + } + } + + /** + * Pythagoras distance between two points. + */ + private float distance(float x0, float x1, float y0, float y1) { + float x = x0 - x1; + float y = y0 - y1; + return (float) Math.sqrt(x * x + y * y); + } + + /** + * Releases all resources the view is using and resets the state, nulling any fields that use significant memory. + * After you have called this method, the view can be re-used by setting a new image. Settings are remembered + * but state (scale and center) is forgotten. You can restore these yourself if required. + */ + public void recycle() { + reset(true); + bitmapPaint = null; + debugTextPaint = null; + debugLinePaint = null; + tileBgPaint = null; + } + + /** + * Convert screen to source x coordinate. + */ + private float viewToSourceX(float vx) { + if (vTranslate == null) { + return Float.NaN; + } + return (vx - vTranslate.x) / scale; + } + + /** + * Convert screen to source y coordinate. + */ + private float viewToSourceY(float vy) { + if (vTranslate == null) { + return Float.NaN; + } + return (vy - vTranslate.y) / scale; + } + + /** + * Converts a rectangle within the view to the corresponding rectangle from the source file, taking + * into account the current scale, translation, orientation and clipped region. This can be used + * to decode a bitmap from the source file. + * <p> + * This method will only work when the image has fully initialised, after {@link #isReady()} returns + * true. It is not guaranteed to work with preloaded bitmaps. + * <p> + * The result is written to the fRect argument. Re-use a single instance for efficiency. + * + * @param vRect rectangle representing the view area to interpret. + * @param fRect rectangle instance to which the result will be written. Re-use for efficiency. + */ + public void viewToFileRect(Rect vRect, Rect fRect) { + if (vTranslate == null || !readySent) { + return; + } + fRect.set( + (int) viewToSourceX(vRect.left), + (int) viewToSourceY(vRect.top), + (int) viewToSourceX(vRect.right), + (int) viewToSourceY(vRect.bottom)); + fileSRect(fRect, fRect); + fRect.set( + Math.max(0, fRect.left), + Math.max(0, fRect.top), + Math.min(sWidth, fRect.right), + Math.min(sHeight, fRect.bottom) + ); + if (sRegion != null) { + fRect.offset(sRegion.left, sRegion.top); + } + } + + /** + * Find the area of the source file that is currently visible on screen, taking into account the + * current scale, translation, orientation and clipped region. This is a convenience method; see + * {@link #viewToFileRect(Rect, Rect)}. + * + * @param fRect rectangle instance to which the result will be written. Re-use for efficiency. + */ + public void visibleFileRect(Rect fRect) { + if (vTranslate == null || !readySent) { + return; + } + fRect.set(0, 0, getWidth(), getHeight()); + viewToFileRect(fRect, fRect); + } + + /** + * Convert screen coordinate to source coordinate. + * + * @param vxy view X/Y coordinate. + * @return a coordinate representing the corresponding source coordinate. + */ + @Nullable + public final PointF viewToSourceCoord(PointF vxy) { + return viewToSourceCoord(vxy.x, vxy.y, new PointF()); + } + + /** + * Convert screen coordinate to source coordinate. + * + * @param vx view X coordinate. + * @param vy view Y coordinate. + * @return a coordinate representing the corresponding source coordinate. + */ + @Nullable + public final PointF viewToSourceCoord(float vx, float vy) { + return viewToSourceCoord(vx, vy, new PointF()); + } + + /** + * Convert screen coordinate to source coordinate. + * + * @param vxy view coordinates to convert. + * @param sTarget target object for result. The same instance is also returned. + * @return source coordinates. This is the same instance passed to the sTarget param. + */ + @Nullable + public final PointF viewToSourceCoord(PointF vxy, @NonNull PointF sTarget) { + return viewToSourceCoord(vxy.x, vxy.y, sTarget); + } + + /** + * Convert screen coordinate to source coordinate. + * + * @param vx view X coordinate. + * @param vy view Y coordinate. + * @param sTarget target object for result. The same instance is also returned. + * @return source coordinates. This is the same instance passed to the sTarget param. + */ + @Nullable + public final PointF viewToSourceCoord(float vx, float vy, @NonNull PointF sTarget) { + if (vTranslate == null) { + return null; + } + sTarget.set(viewToSourceX(vx), viewToSourceY(vy)); + return sTarget; + } + + /** + * Convert source to view x coordinate. + */ + private float sourceToViewX(float sx) { + if (vTranslate == null) { + return Float.NaN; + } + return (sx * scale) + vTranslate.x; + } + + /** + * Convert source to view y coordinate. + */ + private float sourceToViewY(float sy) { + if (vTranslate == null) { + return Float.NaN; + } + return (sy * scale) + vTranslate.y; + } + + /** + * Convert source coordinate to view coordinate. + * + * @param sxy source coordinates to convert. + * @return view coordinates. + */ + @Nullable + public final PointF sourceToViewCoord(PointF sxy) { + return sourceToViewCoord(sxy.x, sxy.y, new PointF()); + } + + /** + * Convert source coordinate to view coordinate. + * + * @param sx source X coordinate. + * @param sy source Y coordinate. + * @return view coordinates. + */ + @Nullable + public final PointF sourceToViewCoord(float sx, float sy) { + return sourceToViewCoord(sx, sy, new PointF()); + } + + /** + * Convert source coordinate to view coordinate. + * + * @param sxy source coordinates to convert. + * @param vTarget target object for result. The same instance is also returned. + * @return view coordinates. This is the same instance passed to the vTarget param. + */ + @SuppressWarnings("UnusedReturnValue") + @Nullable + public final PointF sourceToViewCoord(PointF sxy, @NonNull PointF vTarget) { + return sourceToViewCoord(sxy.x, sxy.y, vTarget); + } + + /** + * Convert source coordinate to view coordinate. + * + * @param sx source X coordinate. + * @param sy source Y coordinate. + * @param vTarget target object for result. The same instance is also returned. + * @return view coordinates. This is the same instance passed to the vTarget param. + */ + @Nullable + public final PointF sourceToViewCoord(float sx, float sy, @NonNull PointF vTarget) { + if (vTranslate == null) { + return null; + } + vTarget.set(sourceToViewX(sx), sourceToViewY(sy)); + return vTarget; + } + + /** + * Convert source rect to screen rect, integer values. + */ + private void sourceToViewRect(@NonNull Rect sRect, @NonNull Rect vTarget) { + vTarget.set( + (int) sourceToViewX(sRect.left), + (int) sourceToViewY(sRect.top), + (int) sourceToViewX(sRect.right), + (int) sourceToViewY(sRect.bottom) + ); + } + + /** + * Get the translation required to place a given source coordinate at the center of the screen, with the center + * adjusted for asymmetric padding. Accepts the desired scale as an argument, so this is independent of current + * translate and scale. The result is fitted to bounds, putting the image point as near to the screen center as permitted. + */ + @NonNull + private PointF vTranslateForSCenter(float sCenterX, float sCenterY, float scale) { + int vxCenter = getPaddingLeft() + (getWidth() - getPaddingRight() - getPaddingLeft()) / 2; + int vyCenter = getPaddingTop() + (getHeight() - getPaddingBottom() - getPaddingTop()) / 2; + if (satTemp == null) { + satTemp = new ScaleAndTranslate(0, new PointF(0, 0)); + } + satTemp.scale = scale; + satTemp.vTranslate.set(vxCenter - (sCenterX * scale), vyCenter - (sCenterY * scale)); + fitToBounds(true, satTemp); + return satTemp.vTranslate; + } + + /** + * Given a requested source center and scale, calculate what the actual center will have to be to keep the image in + * pan limits, keeping the requested center as near to the middle of the screen as allowed. + */ + @NonNull + private PointF limitedSCenter(float sCenterX, float sCenterY, float scale, @NonNull PointF sTarget) { + PointF vTranslate = vTranslateForSCenter(sCenterX, sCenterY, scale); + int vxCenter = getPaddingLeft() + (getWidth() - getPaddingRight() - getPaddingLeft()) / 2; + int vyCenter = getPaddingTop() + (getHeight() - getPaddingBottom() - getPaddingTop()) / 2; + float sx = (vxCenter - vTranslate.x) / scale; + float sy = (vyCenter - vTranslate.y) / scale; + sTarget.set(sx, sy); + return sTarget; + } + + /** + * Returns the minimum allowed scale. + */ + private float minScale() { + int vPadding = getPaddingBottom() + getPaddingTop(); + int hPadding = getPaddingLeft() + getPaddingRight(); + if (minimumScaleType == SCALE_TYPE_CENTER_CROP || minimumScaleType == SCALE_TYPE_START) { + return Math.max((getWidth() - hPadding) / (float) sWidth(), (getHeight() - vPadding) / (float) sHeight()); + } else if (minimumScaleType == SCALE_TYPE_CUSTOM && minScale > 0) { + return minScale; + } else { + return Math.min((getWidth() - hPadding) / (float) sWidth(), (getHeight() - vPadding) / (float) sHeight()); + } + } + + /** + * Adjust a requested scale to be within the allowed limits. + */ + private float limitedScale(float targetScale) { + targetScale = Math.max(minScale(), targetScale); + targetScale = Math.min(maxScale, targetScale); + return targetScale; + } + + /** + * Apply a selected type of easing. + * + * @param type Easing type, from static fields + * @param time Elapsed time + * @param from Start value + * @param change Target value + * @param duration Anm duration + * @return Current value + */ + private float ease(int type, long time, float from, float change, long duration) { + switch (type) { + case EASE_IN_OUT_QUAD: + return easeInOutQuad(time, from, change, duration); + case EASE_OUT_QUAD: + return easeOutQuad(time, from, change, duration); + default: + throw new IllegalStateException("Unexpected easing type: " + type); + } + } + + /** + * Quadratic easing for fling. With thanks to Robert Penner - http://gizma.com/easing/ + * + * @param time Elapsed time + * @param from Start value + * @param change Target value + * @param duration Anm duration + * @return Current value + */ + private float easeOutQuad(long time, float from, float change, long duration) { + float progress = (float) time / (float) duration; + return -change * progress * (progress - 2) + from; + } + + /** + * Quadratic easing for scale and center animations. With thanks to Robert Penner - http://gizma.com/easing/ + * + * @param time Elapsed time + * @param from Start value + * @param change Target value + * @param duration Anm duration + * @return Current value + */ + private float easeInOutQuad(long time, float from, float change, long duration) { + float timeF = time / (duration / 2f); + if (timeF < 1) { + return (change / 2f * timeF * timeF) + from; + } else { + timeF--; + return (-change / 2f) * (timeF * (timeF - 2) - 1) + from; + } + } + + /** + * Debug logger + */ + @AnyThread + private void debug(String message, Object... args) { + if (debug) { + SLog.INSTANCE.d(TAG, String.format(message, args)); + } + } + + /** + * For debug overlays. Scale pixel value according to screen density. + */ + private int px(int px) { + return (int) (density * px); + } + + /** + * Swap the default region decoder implementation for one of your own. You must do this before setting the image file or + * asset, and you cannot use a custom decoder when using layout XML to set an asset name. Your class must have a + * public default constructor. + * + * @param regionDecoderClass The {@link ImageRegionDecoder} implementation to use. + */ + public final void setRegionDecoderClass(@NonNull Class<? extends ImageRegionDecoder> regionDecoderClass) { + //noinspection ConstantConditions + if (regionDecoderClass == null) { + throw new IllegalArgumentException("Decoder class cannot be set to null"); + } + this.regionDecoderFactory = new CompatDecoderFactory<>(regionDecoderClass); + } + + /** + * Swap the default region decoder implementation for one of your own. You must do this before setting the image file or + * asset, and you cannot use a custom decoder when using layout XML to set an asset name. + * + * @param regionDecoderFactory The {@link DecoderFactory} implementation that produces {@link ImageRegionDecoder} + * instances. + */ + public final void setRegionDecoderFactory(@NonNull DecoderFactory<? extends ImageRegionDecoder> regionDecoderFactory) { + //noinspection ConstantConditions + if (regionDecoderFactory == null) { + throw new IllegalArgumentException("Decoder factory cannot be set to null"); + } + this.regionDecoderFactory = regionDecoderFactory; + } + + /** + * Swap the default bitmap decoder implementation for one of your own. You must do this before setting the image file or + * asset, and you cannot use a custom decoder when using layout XML to set an asset name. Your class must have a + * public default constructor. + * + * @param bitmapDecoderClass The {@link ImageDecoder} implementation to use. + */ + public final void setBitmapDecoderClass(@NonNull Class<? extends ImageDecoder> bitmapDecoderClass) { + //noinspection ConstantConditions + if (bitmapDecoderClass == null) { + throw new IllegalArgumentException("Decoder class cannot be set to null"); + } + this.bitmapDecoderFactory = new CompatDecoderFactory<>(bitmapDecoderClass); + } + + /** + * Swap the default bitmap decoder implementation for one of your own. You must do this before setting the image file or + * asset, and you cannot use a custom decoder when using layout XML to set an asset name. + * + * @param bitmapDecoderFactory The {@link DecoderFactory} implementation that produces {@link ImageDecoder} instances. + */ + public final void setBitmapDecoderFactory(@NonNull DecoderFactory<? extends ImageDecoder> bitmapDecoderFactory) { + //noinspection ConstantConditions + if (bitmapDecoderFactory == null) { + throw new IllegalArgumentException("Decoder factory cannot be set to null"); + } + this.bitmapDecoderFactory = bitmapDecoderFactory; + } + + /** + * Calculate how much further the image can be panned in each direction. The results are set on + * the supplied {@link RectF} and expressed as screen pixels. For example, if the image cannot be + * panned any further towards the left, the value of {@link RectF#left} will be set to 0. + * + * @param vTarget target object for results. Re-use for efficiency. + */ + public final void getPanRemaining(RectF vTarget) { + if (!isReady()) { + return; + } + + float scaleWidth = scale * sWidth(); + float scaleHeight = scale * sHeight(); + + if (panLimit == PAN_LIMIT_CENTER) { + vTarget.top = Math.max(0, -(vTranslate.y - (getHeight() / 2))); + vTarget.left = Math.max(0, -(vTranslate.x - (getWidth() / 2))); + vTarget.bottom = Math.max(0, vTranslate.y - ((getHeight() / 2) - scaleHeight)); + vTarget.right = Math.max(0, vTranslate.x - ((getWidth() / 2) - scaleWidth)); + } else if (panLimit == PAN_LIMIT_OUTSIDE) { + vTarget.top = Math.max(0, -(vTranslate.y - getHeight())); + vTarget.left = Math.max(0, -(vTranslate.x - getWidth())); + vTarget.bottom = Math.max(0, vTranslate.y + scaleHeight); + vTarget.right = Math.max(0, vTranslate.x + scaleWidth); + } else { + vTarget.top = Math.max(0, -vTranslate.y); + vTarget.left = Math.max(0, -vTranslate.x); + vTarget.bottom = Math.max(0, (scaleHeight + vTranslate.y) - getHeight()); + vTarget.right = Math.max(0, (scaleWidth + vTranslate.x) - getWidth()); + } + } + + /** + * Set the pan limiting style. See static fields. Normally {@link #PAN_LIMIT_INSIDE} is best, for image galleries. + * + * @param panLimit a pan limit constant. See static fields. + */ + public final void setPanLimit(int panLimit) { + if (!VALID_PAN_LIMITS.contains(panLimit)) { + throw new IllegalArgumentException("Invalid pan limit: " + panLimit); + } + this.panLimit = panLimit; + if (isReady()) { + fitToBounds(true); + invalidate(); + } + } + + /** + * Set the minimum scale type. See static fields. Normally {@link #SCALE_TYPE_CENTER_INSIDE} is best, for image galleries. + * + * @param scaleType a scale type constant. See static fields. + */ + public final void setMinimumScaleType(int scaleType) { + if (!VALID_SCALE_TYPES.contains(scaleType)) { + throw new IllegalArgumentException("Invalid scale type: " + scaleType); + } + this.minimumScaleType = scaleType; + if (isReady()) { + fitToBounds(true); + invalidate(); + } + } + + /** + * This is a screen density aware alternative to {@link #setMaxScale(float)}; it allows you to express the maximum + * allowed scale in terms of the minimum pixel density. This avoids the problem of 1:1 scale still being + * too small on a high density screen. A sensible starting point is 160 - the default used by this view. + * + * @param dpi Source image pixel density at maximum zoom. + */ + public final void setMinimumDpi(int dpi) { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + float averageDpi = (metrics.xdpi + metrics.ydpi) / 2; + setMaxScale(averageDpi / dpi); + } + + /** + * This is a screen density aware alternative to {@link #setMinScale(float)}; it allows you to express the minimum + * allowed scale in terms of the maximum pixel density. + * + * @param dpi Source image pixel density at minimum zoom. + */ + public final void setMaximumDpi(int dpi) { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + float averageDpi = (metrics.xdpi + metrics.ydpi) / 2; + setMinScale(averageDpi / dpi); + } + + /** + * Returns the maximum allowed scale. + * + * @return the maximum scale as a source/view pixels ratio. + */ + public float getMaxScale() { + return maxScale; + } + + /** + * Set the maximum scale allowed. A value of 1 means 1:1 pixels at maximum scale. You may wish to set this according + * to screen density - on a retina screen, 1:1 may still be too small. Consider using {@link #setMinimumDpi(int)}, + * which is density aware. + * + * @param maxScale maximum scale expressed as a source/view pixels ratio. + */ + public final void setMaxScale(float maxScale) { + this.maxScale = maxScale; + } + + /** + * Returns the minimum allowed scale. + * + * @return the minimum scale as a source/view pixels ratio. + */ + public final float getMinScale() { + return minScale(); + } + + /** + * Set the minimum scale allowed. A value of 1 means 1:1 pixels at minimum scale. You may wish to set this according + * to screen density. Consider using {@link #setMaximumDpi(int)}, which is density aware. + * + * @param minScale minimum scale expressed as a source/view pixels ratio. + */ + public final void setMinScale(float minScale) { + this.minScale = minScale; + } + + /** + * By default, image tiles are at least as high resolution as the screen. For a retina screen this may not be + * necessary, and may increase the likelihood of an OutOfMemoryError. This method sets a DPI at which higher + * resolution tiles should be loaded. Using a lower number will on average use less memory but result in a lower + * quality image. 160-240dpi will usually be enough. This should be called before setting the image source, + * because it affects which tiles get loaded. When using an untiled source image this method has no effect. + * + * @param minimumTileDpi Tile loading threshold. + */ + public void setMinimumTileDpi(int minimumTileDpi) { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + float averageDpi = (metrics.xdpi + metrics.ydpi) / 2; + this.minimumTileDpi = (int) Math.min(averageDpi, minimumTileDpi); + if (isReady()) { + reset(false); + invalidate(); + } + } + + /** + * Returns the source point at the center of the view. + * + * @return the source coordinates current at the center of the view. + */ + @Nullable + public final PointF getCenter() { + int mX = getWidth() / 2; + int mY = getHeight() / 2; + return viewToSourceCoord(mX, mY); + } + + /** + * Returns the current scale value. + * + * @return the current scale as a source/view pixels ratio. + */ + public final float getScale() { + return scale; + } + + /** + * Externally change the scale and translation of the source image. This may be used with getCenter() and getScale() + * to restore the scale and zoom after a screen rotate. + * + * @param scale New scale to set. + * @param sCenter New source image coordinate to center on the screen, subject to boundaries. + */ + public final void setScaleAndCenter(float scale, @Nullable PointF sCenter) { + this.anim = null; + this.pendingScale = scale; + this.sPendingCenter = sCenter; + this.sRequestedCenter = sCenter; + invalidate(); + } + + /** + * Fully zoom out and return the image to the middle of the screen. This might be useful if you have a view pager + * and want images to be reset when the user has moved to another page. + */ + public final void resetScaleAndCenter() { + this.anim = null; + this.pendingScale = limitedScale(0); + if (isReady()) { + this.sPendingCenter = new PointF(sWidth() / 2, sHeight() / 2); + } else { + this.sPendingCenter = new PointF(0, 0); + } + invalidate(); + } + + /** + * Call to find whether the view is initialised, has dimensions, and will display an image on + * the next draw. If a preview has been provided, it may be the preview that will be displayed + * and the full size image may still be loading. If no preview was provided, this is called once + * the base layer tiles of the full size image are loaded. + * + * @return true if the view is ready to display an image and accept touch gestures. + */ + public final boolean isReady() { + return readySent; + } + + /** + * Called once when the view is initialised, has dimensions, and will display an image on the + * next draw. This is triggered at the same time as {@link OnImageEventListener#onReady()} but + * allows a subclass to receive this event without using a listener. + */ + @SuppressWarnings("EmptyMethod") + protected void onReady() { + + } + + /** + * Call to find whether the main image (base layer tiles where relevant) have been loaded. Before + * this event the view is blank unless a preview was provided. + * + * @return true if the main image (not the preview) has been loaded and is ready to display. + */ + public final boolean isImageLoaded() { + return imageLoadedSent; + } + + /** + * Called once when the full size image or its base layer tiles have been loaded. + */ + @SuppressWarnings("EmptyMethod") + protected void onImageLoaded() { + + } + + /** + * Get source width, ignoring orientation. If {@link #getOrientation()} returns 90 or 270, you can use {@link #getSHeight()} + * for the apparent width. + * + * @return the source image width in pixels. + */ + public final int getSWidth() { + return sWidth; + } + + /** + * Get source height, ignoring orientation. If {@link #getOrientation()} returns 90 or 270, you can use {@link #getSWidth()} + * for the apparent height. + * + * @return the source image height in pixels. + */ + public final int getSHeight() { + return sHeight; + } + + /** + * Returns the orientation setting. This can return {@link #ORIENTATION_USE_EXIF}, in which case it doesn't tell you + * the applied orientation of the image. For that, use {@link #getAppliedOrientation()}. + * + * @return the orientation setting. See static fields. + */ + public final int getOrientation() { + return orientation; + } + + /** + * Sets the image orientation. It's best to call this before setting the image file or asset, because it may waste + * loading of tiles. However, this can be freely called at any time. + * + * @param orientation orientation to be set. See ORIENTATION_ static fields for valid values. + */ + public final void setOrientation(int orientation) { + if (!VALID_ORIENTATIONS.contains(orientation)) { + throw new IllegalArgumentException("Invalid orientation: " + orientation); + } + this.orientation = orientation; + reset(false); + invalidate(); + requestLayout(); + } + + /** + * Returns the actual orientation of the image relative to the source file. This will be based on the source file's + * EXIF orientation if you're using ORIENTATION_USE_EXIF. Values are 0, 90, 180, 270. + * + * @return the orientation applied after EXIF information has been extracted. See static fields. + */ + public final int getAppliedOrientation() { + return getRequiredRotation(); + } + + /** + * Get the current state of the view (scale, center, orientation) for restoration after rotate. Will return null if + * the view is not ready. + * + * @return an {@link ImageViewState} instance representing the current position of the image. null if the view isn't ready. + */ + @Nullable + public final ImageViewState getState() { + if (vTranslate != null && sWidth > 0 && sHeight > 0) { + //noinspection ConstantConditions + return new ImageViewState(getScale(), getCenter(), getOrientation()); + } + return null; + } + + /** + * Returns true if zoom gesture detection is enabled. + * + * @return true if zoom gesture detection is enabled. + */ + public final boolean isZoomEnabled() { + return zoomEnabled; + } + + /** + * Enable or disable zoom gesture detection. Disabling zoom locks the the current scale. + * + * @param zoomEnabled true to enable zoom gestures, false to disable. + */ + public final void setZoomEnabled(boolean zoomEnabled) { + this.zoomEnabled = zoomEnabled; + } + + /** + * Returns true if double tap & swipe to zoom is enabled. + * + * @return true if double tap & swipe to zoom is enabled. + */ + public final boolean isQuickScaleEnabled() { + return quickScaleEnabled; + } + + /** + * Enable or disable double tap & swipe to zoom. + * + * @param quickScaleEnabled true to enable quick scale, false to disable. + */ + public final void setQuickScaleEnabled(boolean quickScaleEnabled) { + this.quickScaleEnabled = quickScaleEnabled; + } + + /** + * Returns true if pan gesture detection is enabled. + * + * @return true if pan gesture detection is enabled. + */ + public final boolean isPanEnabled() { + return panEnabled; + } + + /** + * Enable or disable pan gesture detection. Disabling pan causes the image to be centered. Pan + * can still be changed from code. + * + * @param panEnabled true to enable panning, false to disable. + */ + public final void setPanEnabled(boolean panEnabled) { + this.panEnabled = panEnabled; + if (!panEnabled && vTranslate != null) { + vTranslate.x = (getWidth() / 2) - (scale * (sWidth() / 2)); + vTranslate.y = (getHeight() / 2) - (scale * (sHeight() / 2)); + if (isReady()) { + refreshRequiredTiles(true); + invalidate(); + } + } + } + + /** + * Set a solid color to render behind tiles, useful for displaying transparent PNGs. + * + * @param tileBgColor Background color for tiles. + */ + public final void setTileBackgroundColor(int tileBgColor) { + if (Color.alpha(tileBgColor) == 0) { + tileBgPaint = null; + } else { + tileBgPaint = new Paint(); + tileBgPaint.setStyle(Style.FILL); + tileBgPaint.setColor(tileBgColor); + } + invalidate(); + } + + /** + * Set the scale the image will zoom in to when double tapped. This also the scale point where a double tap is interpreted + * as a zoom out gesture - if the scale is greater than 90% of this value, a double tap zooms out. Avoid using values + * greater than the max zoom. + * + * @param doubleTapZoomScale New value for double tap gesture zoom scale. + */ + public final void setDoubleTapZoomScale(float doubleTapZoomScale) { + this.doubleTapZoomScale = doubleTapZoomScale; + } + + /** + * A density aware alternative to {@link #setDoubleTapZoomScale(float)}; this allows you to express the scale the + * image will zoom in to when double tapped in terms of the image pixel density. Values lower than the max scale will + * be ignored. A sensible starting point is 160 - the default used by this view. + * + * @param dpi New value for double tap gesture zoom scale. + */ + public final void setDoubleTapZoomDpi(int dpi) { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + float averageDpi = (metrics.xdpi + metrics.ydpi) / 2; + setDoubleTapZoomScale(averageDpi / dpi); + } + + /** + * Set the type of zoom animation to be used for double taps. See static fields. + * + * @param doubleTapZoomStyle New value for zoom style. + */ + public final void setDoubleTapZoomStyle(int doubleTapZoomStyle) { + if (!VALID_ZOOM_STYLES.contains(doubleTapZoomStyle)) { + throw new IllegalArgumentException("Invalid zoom style: " + doubleTapZoomStyle); + } + this.doubleTapZoomStyle = doubleTapZoomStyle; + } + + /** + * Set the duration of the double tap zoom animation. + * + * @param durationMs Duration in milliseconds. + */ + public final void setDoubleTapZoomDuration(int durationMs) { + this.doubleTapZoomDuration = Math.max(0, durationMs); + } + + /** + * <p> + * Provide an {@link Executor} to be used for loading images. By default, {@link AsyncTask#THREAD_POOL_EXECUTOR} + * is used to minimise contention with other background work the app is doing. You can also choose + * to use {@link AsyncTask#SERIAL_EXECUTOR} if you want to limit concurrent background tasks. + * Alternatively you can supply an {@link Executor} of your own to avoid any contention. It is + * strongly recommended to use a single executor instance for the life of your application, not + * one per view instance. + * </p><p> + * <b>Warning:</b> If you are using a custom implementation of {@link ImageRegionDecoder}, and you + * supply an executor with more than one thread, you must make sure your implementation supports + * multi-threaded bitmap decoding or has appropriate internal synchronization. From SDK 21, Android's + * {@link android.graphics.BitmapRegionDecoder} uses an internal lock so it is thread safe but + * there is no advantage to using multiple threads. + * </p> + * + * @param executor an {@link Executor} for image loading. + */ + public void setExecutor(@NonNull Executor executor) { + //noinspection ConstantConditions + if (executor == null) { + throw new NullPointerException("Executor must not be null"); + } + this.executor = executor; + } + + /** + * Enable or disable eager loading of tiles that appear on screen during gestures or animations, + * while the gesture or animation is still in progress. By default this is enabled to improve + * responsiveness, but it can result in tiles being loaded and discarded more rapidly than + * necessary and reduce the animation frame rate on old/cheap devices. Disable this on older + * devices if you see poor performance. Tiles will then be loaded only when gestures and animations + * are completed. + * + * @param eagerLoadingEnabled true to enable loading during gestures, false to delay loading until gestures end + */ + public void setEagerLoadingEnabled(boolean eagerLoadingEnabled) { + this.eagerLoadingEnabled = eagerLoadingEnabled; + } + + /** + * Enables visual debugging, showing tile boundaries and sizes. + * + * @param debug true to enable debugging, false to disable. + */ + public final void setDebug(boolean debug) { + this.debug = debug; + } + + /** + * Check if an image has been set. The image may not have been loaded and displayed yet. + * + * @return If an image is currently set. + */ + public boolean hasImage() { + return uri != null || bitmap != null; + } + + /** + * {@inheritDoc} + */ + @Override + public void setOnLongClickListener(OnLongClickListener onLongClickListener) { + this.onLongClickListener = onLongClickListener; + } + + /** + * Add a listener allowing notification of load and error events. Extend {@link DefaultOnImageEventListener} + * to simplify implementation. + * + * @param onImageEventListener an {@link OnImageEventListener} instance. + */ + public void setOnImageEventListener(OnImageEventListener onImageEventListener) { + this.onImageEventListener = onImageEventListener; + } + + /** + * Add a listener for pan and zoom events. Extend {@link DefaultOnStateChangedListener} to simplify + * implementation. + * + * @param onStateChangedListener an {@link OnStateChangedListener} instance. + */ + public void setOnStateChangedListener(OnStateChangedListener onStateChangedListener) { + this.onStateChangedListener = onStateChangedListener; + } + + private void sendStateChanged(float oldScale, PointF oldVTranslate, int origin) { + if (onStateChangedListener != null && scale != oldScale) { + onStateChangedListener.onScaleChanged(scale, origin); + } + if (onStateChangedListener != null && !vTranslate.equals(oldVTranslate)) { + onStateChangedListener.onCenterChanged(getCenter(), origin); + } + } + + /** + * Creates a panning animation builder, that when started will animate the image to place the given coordinates of + * the image in the center of the screen. If doing this would move the image beyond the edges of the screen, the + * image is instead animated to move the center point as near to the center of the screen as is allowed - it's + * guaranteed to be on screen. + * + * @param sCenter Target center point + * @return {@link AnimationBuilder} instance. Call {@link AnimationBuilder#start()} to start the anim. + */ + @Nullable + public AnimationBuilder animateCenter(PointF sCenter) { + if (!isReady()) { + return null; + } + return new AnimationBuilder(sCenter); + } + + /** + * Creates a scale animation builder, that when started will animate a zoom in or out. If this would move the image + * beyond the panning limits, the image is automatically panned during the animation. + * + * @param scale Target scale. + * @return {@link AnimationBuilder} instance. Call {@link AnimationBuilder#start()} to start the anim. + */ + @Nullable + public AnimationBuilder animateScale(float scale) { + if (!isReady()) { + return null; + } + return new AnimationBuilder(scale); + } + + /** + * Creates a scale animation builder, that when started will animate a zoom in or out. If this would move the image + * beyond the panning limits, the image is automatically panned during the animation. + * + * @param scale Target scale. + * @param sCenter Target source center. + * @return {@link AnimationBuilder} instance. Call {@link AnimationBuilder#start()} to start the anim. + */ + @Nullable + public AnimationBuilder animateScaleAndCenter(float scale, PointF sCenter) { + if (!isReady()) { + return null; + } + return new AnimationBuilder(scale, sCenter); + } + + public int getMaxTouchCount() { + return maxTouchCount; + } + + public boolean isAtXEdge() { + return atXEdge; + } + + public boolean isAtYEdge() { + return atYEdge; + } + + /** + * An event listener for animations, allows events to be triggered when an animation completes, + * is aborted by another animation starting, or is aborted by a touch event. Note that none of + * these events are triggered if the activity is paused, the image is swapped, or in other cases + * where the view's internal state gets wiped or draw events stop. + */ + @SuppressWarnings("EmptyMethod") + public interface OnAnimationEventListener { + + /** + * The animation has completed, having reached its endpoint. + */ + void onComplete(); + + /** + * The animation has been aborted before reaching its endpoint because the user touched the screen. + */ + void onInterruptedByUser(); + + /** + * The animation has been aborted before reaching its endpoint because a new animation has been started. + */ + void onInterruptedByNewAnim(); + + } + + /** + * An event listener, allowing subclasses and activities to be notified of significant events. + */ + @SuppressWarnings("EmptyMethod") + public interface OnImageEventListener { + + /** + * Called when the dimensions of the image and view are known, and either a preview image, + * the full size image, or base layer tiles are loaded. This indicates the scale and translate + * are known and the next draw will display an image. This event can be used to hide a loading + * graphic, or inform a subclass that it is safe to draw overlays. + */ + void onReady(); + + /** + * Called when the full size image is ready. When using tiling, this means the lowest resolution + * base layer of tiles are loaded, and when tiling is disabled, the image bitmap is loaded. + * This event could be used as a trigger to enable gestures if you wanted interaction disabled + * while only a preview is displayed, otherwise for most cases {@link #onReady()} is the best + * event to listen to. + */ + void onImageLoaded(); + + /** + * Called when a preview image could not be loaded. This method cannot be relied upon; certain + * encoding types of supported image formats can result in corrupt or blank images being loaded + * and displayed with no detectable error. The view will continue to load the full size image. + * + * @param e The exception thrown. This error is logged by the view. + */ + void onPreviewLoadError(Exception e); + + /** + * Indicates an error initiliasing the decoder when using a tiling, or when loading the full + * size bitmap when tiling is disabled. This method cannot be relied upon; certain encoding + * types of supported image formats can result in corrupt or blank images being loaded and + * displayed with no detectable error. + * + * @param e The exception thrown. This error is also logged by the view. + */ + void onImageLoadError(Exception e); + + /** + * Called when an image tile could not be loaded. This method cannot be relied upon; certain + * encoding types of supported image formats can result in corrupt or blank images being loaded + * and displayed with no detectable error. Most cases where an unsupported file is used will + * result in an error caught by {@link #onImageLoadError(Exception)}. + * + * @param e The exception thrown. This error is logged by the view. + */ + void onTileLoadError(Exception e); + + /** + * Called when a bitmap set using ImageSource.cachedBitmap is no longer being used by the View. + * This is useful if you wish to manage the bitmap after the preview is shown + */ + void onPreviewReleased(); + } + + /** + * An event listener, allowing activities to be notified of pan and zoom events. Initialisation + * and calls made by your code do not trigger events; touch events and animations do. Methods in + * this listener will be called on the UI thread and may be called very frequently - your + * implementation should return quickly. + */ + @SuppressWarnings("EmptyMethod") + public interface OnStateChangedListener { + + /** + * The scale has changed. Use with {@link #getMaxScale()} and {@link #getMinScale()} to determine + * whether the image is fully zoomed in or out. + * + * @param newScale The new scale. + * @param origin Where the event originated from - one of {@link #ORIGIN_ANIM}, {@link #ORIGIN_TOUCH}. + */ + void onScaleChanged(float newScale, int origin); + + /** + * The source center has been changed. This can be a result of panning or zooming. + * + * @param newCenter The new source center point. + * @param origin Where the event originated from - one of {@link #ORIGIN_ANIM}, {@link #ORIGIN_TOUCH}. + */ + void onCenterChanged(PointF newCenter, int origin); + + } + + /** + * Async task used to get image details without blocking the UI thread. + */ + private static class TilesInitTask extends AsyncTask<Void, Void, int[]> { + private final WeakReference<SubsamplingScaleImageView> viewRef; + private final WeakReference<Context> contextRef; + private final WeakReference<DecoderFactory<? extends ImageRegionDecoder>> decoderFactoryRef; + private final Uri source; + private ImageRegionDecoder decoder; + private Exception exception; + + TilesInitTask(SubsamplingScaleImageView view, Context context, DecoderFactory<? extends ImageRegionDecoder> decoderFactory, Uri source) { + this.viewRef = new WeakReference<>(view); + this.contextRef = new WeakReference<>(context); + this.decoderFactoryRef = new WeakReference<DecoderFactory<? extends ImageRegionDecoder>>(decoderFactory); + this.source = source; + } + + @Override + protected int[] doInBackground(Void... params) { + try { + String sourceUri = source.toString(); + Context context = contextRef.get(); + DecoderFactory<? extends ImageRegionDecoder> decoderFactory = decoderFactoryRef.get(); + SubsamplingScaleImageView view = viewRef.get(); + if (context != null && decoderFactory != null && view != null) { + view.debug("TilesInitTask.doInBackground"); + decoder = decoderFactory.make(); + Point dimensions = decoder.init(context, source); + int sWidth = dimensions.x; + int sHeight = dimensions.y; + int exifOrientation = view.getExifOrientation(context, sourceUri); + if (view.sRegion != null) { + view.sRegion.left = Math.max(0, view.sRegion.left); + view.sRegion.top = Math.max(0, view.sRegion.top); + view.sRegion.right = Math.min(sWidth, view.sRegion.right); + view.sRegion.bottom = Math.min(sHeight, view.sRegion.bottom); + sWidth = view.sRegion.width(); + sHeight = view.sRegion.height(); + } + return new int[]{sWidth, sHeight, exifOrientation}; + } + } catch (Exception e) { + SLog.INSTANCE.e(TAG, "Failed to initialise bitmap decoder", e); + this.exception = e; + } + return null; + } + + @Override + protected void onPostExecute(int[] xyo) { + final SubsamplingScaleImageView view = viewRef.get(); + if (view != null) { + if (decoder != null && xyo != null && xyo.length == 3) { + view.onTilesInited(decoder, xyo[0], xyo[1], xyo[2]); + } else if (exception != null && view.onImageEventListener != null) { + view.onImageEventListener.onImageLoadError(exception); + } + } + } + } + + /** + * Async task used to load images without blocking the UI thread. + */ + private static class TileLoadTask extends AsyncTask<Void, Void, Bitmap> { + private final WeakReference<SubsamplingScaleImageView> viewRef; + private final WeakReference<ImageRegionDecoder> decoderRef; + private final WeakReference<Tile> tileRef; + private Exception exception; + + TileLoadTask(SubsamplingScaleImageView view, ImageRegionDecoder decoder, Tile tile) { + this.viewRef = new WeakReference<>(view); + this.decoderRef = new WeakReference<>(decoder); + this.tileRef = new WeakReference<>(tile); + tile.loading = true; + } + + @Override + protected Bitmap doInBackground(Void... params) { + try { + SubsamplingScaleImageView view = viewRef.get(); + ImageRegionDecoder decoder = decoderRef.get(); + Tile tile = tileRef.get(); + if (decoder != null && tile != null && view != null && decoder.isReady() && tile.visible) { + view.debug("TileLoadTask.doInBackground, tile.sRect=%s, tile.sampleSize=%d", tile.sRect, tile.sampleSize); + view.decoderLock.readLock().lock(); + try { + if (decoder.isReady()) { + // Update tile's file sRect according to rotation + view.fileSRect(tile.sRect, tile.fileSRect); + if (view.sRegion != null) { + tile.fileSRect.offset(view.sRegion.left, view.sRegion.top); + } + return decoder.decodeRegion(tile.fileSRect, tile.sampleSize); + } else { + tile.loading = false; + } + } finally { + view.decoderLock.readLock().unlock(); + } + } else if (tile != null) { + tile.loading = false; + } + } catch (Exception e) { + SLog.INSTANCE.e(TAG, "Failed to decode tile", e); + this.exception = e; + } catch (OutOfMemoryError e) { + SLog.INSTANCE.e(TAG, "Failed to decode tile - OutOfMemoryError", e); + this.exception = new RuntimeException(e); + } + return null; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + final SubsamplingScaleImageView subsamplingScaleImageView = viewRef.get(); + final Tile tile = tileRef.get(); + if (subsamplingScaleImageView != null && tile != null) { + if (bitmap != null) { + tile.bitmap = bitmap; + tile.loading = false; + subsamplingScaleImageView.onTileLoaded(); + } else if (exception != null && subsamplingScaleImageView.onImageEventListener != null) { + subsamplingScaleImageView.onImageEventListener.onTileLoadError(exception); + } + } + } + } + + /** + * Async task used to load bitmap without blocking the UI thread. + */ + private static class BitmapLoadTask extends AsyncTask<Void, Void, Integer> { + private final WeakReference<SubsamplingScaleImageView> viewRef; + private final WeakReference<Context> contextRef; + private final WeakReference<DecoderFactory<? extends ImageDecoder>> decoderFactoryRef; + private final Uri source; + private final boolean preview; + private Bitmap bitmap; + private Exception exception; + + BitmapLoadTask(SubsamplingScaleImageView view, Context context, DecoderFactory<? extends ImageDecoder> decoderFactory, Uri source, boolean preview) { + this.viewRef = new WeakReference<>(view); + this.contextRef = new WeakReference<>(context); + this.decoderFactoryRef = new WeakReference<DecoderFactory<? extends ImageDecoder>>(decoderFactory); + this.source = source; + this.preview = preview; + } + + @Override + protected Integer doInBackground(Void... params) { + try { + String sourceUri = source.toString(); + Context context = contextRef.get(); + DecoderFactory<? extends ImageDecoder> decoderFactory = decoderFactoryRef.get(); + SubsamplingScaleImageView view = viewRef.get(); + if (context != null && decoderFactory != null && view != null) { + view.debug("BitmapLoadTask.doInBackground"); + bitmap = decoderFactory.make().decode(context, source); + return view.getExifOrientation(context, sourceUri); + } + } catch (Exception e) { + SLog.INSTANCE.e(TAG, "Failed to load bitmap", e); + this.exception = e; + } catch (OutOfMemoryError e) { + SLog.INSTANCE.e(TAG, "Failed to load bitmap - OutOfMemoryError", e); + this.exception = new RuntimeException(e); + } + return null; + } + + @Override + protected void onPostExecute(Integer orientation) { + SubsamplingScaleImageView subsamplingScaleImageView = viewRef.get(); + if (subsamplingScaleImageView != null) { + if (bitmap != null && orientation != null) { + if (preview) { + subsamplingScaleImageView.onPreviewLoaded(bitmap); + } else { + subsamplingScaleImageView.onImageLoaded(bitmap, orientation, false); + } + } else if (exception != null && subsamplingScaleImageView.onImageEventListener != null) { + if (preview) { + subsamplingScaleImageView.onImageEventListener.onPreviewLoadError(exception); + } else { + subsamplingScaleImageView.onImageEventListener.onImageLoadError(exception); + } + } + } + } + } + + private static class Tile { + + private Rect sRect; + private int sampleSize; + private Bitmap bitmap; + private boolean loading; + private boolean visible; + + // Volatile fields instantiated once then updated before use to reduce GC. + private Rect vRect; + private Rect fileSRect; + + } + + private static class Anim { + + private float scaleStart; // Scale at start of anim + private float scaleEnd; // Scale at end of anim (target) + private PointF sCenterStart; // Source center point at start + private PointF sCenterEnd; // Source center point at end, adjusted for pan limits + private PointF sCenterEndRequested; // Source center point that was requested, without adjustment + private PointF vFocusStart; // View point that was double tapped + private PointF vFocusEnd; // Where the view focal point should be moved to during the anim + private long duration = 500; // How long the anim takes + private boolean interruptible = true; // Whether the anim can be interrupted by a touch + private int easing = EASE_IN_OUT_QUAD; // Easing style + private int origin = ORIGIN_ANIM; // Animation origin (API, double tap or fling) + private long time = System.currentTimeMillis(); // Start time + private OnAnimationEventListener listener; // Event listener + + } + + private static class ScaleAndTranslate { + private final PointF vTranslate; + private float scale; + private ScaleAndTranslate(float scale, PointF vTranslate) { + this.scale = scale; + this.vTranslate = vTranslate; + } + } + + /** + * Default implementation of {@link OnAnimationEventListener} for extension. This does nothing in any method. + */ + public static class DefaultOnAnimationEventListener implements OnAnimationEventListener { + + @Override + public void onComplete() { + } + + @Override + public void onInterruptedByUser() { + } + + @Override + public void onInterruptedByNewAnim() { + } + + } + + /** + * Default implementation of {@link OnImageEventListener} for extension. This does nothing in any method. + */ + public static class DefaultOnImageEventListener implements OnImageEventListener { + + @Override + public void onReady() { + } + + @Override + public void onImageLoaded() { + } + + @Override + public void onPreviewLoadError(Exception e) { + } + + @Override + public void onImageLoadError(Exception e) { + } + + @Override + public void onTileLoadError(Exception e) { + } + + @Override + public void onPreviewReleased() { + } + + } + + /** + * Default implementation of {@link OnStateChangedListener}. This does nothing in any method. + */ + public static class DefaultOnStateChangedListener implements OnStateChangedListener { + + @Override + public void onCenterChanged(PointF newCenter, int origin) { + } + + @Override + public void onScaleChanged(float newScale, int origin) { + } + + } + + /** + * Builder class used to set additional options for a scale animation. Create an instance using {@link #animateScale(float)}, + * then set your options and call {@link #start()}. + */ + public final class AnimationBuilder { + + private final float targetScale; + private final PointF targetSCenter; + private final PointF vFocus; + private long duration = 500; + private int easing = EASE_IN_OUT_QUAD; + private int origin = ORIGIN_ANIM; + private boolean interruptible = true; + private boolean panLimited = true; + private OnAnimationEventListener listener; + + private AnimationBuilder(PointF sCenter) { + this.targetScale = scale; + this.targetSCenter = sCenter; + this.vFocus = null; + } + + private AnimationBuilder(float scale) { + this.targetScale = scale; + this.targetSCenter = getCenter(); + this.vFocus = null; + } + + private AnimationBuilder(float scale, PointF sCenter) { + this.targetScale = scale; + this.targetSCenter = sCenter; + this.vFocus = null; + } + + private AnimationBuilder(float scale, PointF sCenter, PointF vFocus) { + this.targetScale = scale; + this.targetSCenter = sCenter; + this.vFocus = vFocus; + } + + /** + * Desired duration of the anim in milliseconds. Default is 500. + * + * @param duration duration in milliseconds. + * @return this builder for method chaining. + */ + @NonNull + public AnimationBuilder withDuration(long duration) { + this.duration = duration; + return this; + } + + /** + * Whether the animation can be interrupted with a touch. Default is true. + * + * @param interruptible interruptible flag. + * @return this builder for method chaining. + */ + @NonNull + public AnimationBuilder withInterruptible(boolean interruptible) { + this.interruptible = interruptible; + return this; + } + + /** + * Set the easing style. See static fields. {@link #EASE_IN_OUT_QUAD} is recommended, and the default. + * + * @param easing easing style. + * @return this builder for method chaining. + */ + @NonNull + public AnimationBuilder withEasing(int easing) { + if (!VALID_EASING_STYLES.contains(easing)) { + throw new IllegalArgumentException("Unknown easing type: " + easing); + } + this.easing = easing; + return this; + } + + /** + * Add an animation event listener. + * + * @param listener The listener. + * @return this builder for method chaining. + */ + @NonNull + public AnimationBuilder withOnAnimationEventListener(OnAnimationEventListener listener) { + this.listener = listener; + return this; + } + + /** + * Only for internal use. When set to true, the animation proceeds towards the actual end point - the nearest + * point to the center allowed by pan limits. When false, animation is in the direction of the requested end + * point and is stopped when the limit for each axis is reached. The latter behaviour is used for flings but + * nothing else. + */ + @NonNull + private AnimationBuilder withPanLimited(boolean panLimited) { + this.panLimited = panLimited; + return this; + } + + /** + * Only for internal use. Indicates what caused the animation. + */ + @NonNull + private AnimationBuilder withOrigin(int origin) { + this.origin = origin; + return this; + } + + /** + * Starts the animation. + */ + public void start() { + if (anim != null && anim.listener != null) { + try { + anim.listener.onInterruptedByNewAnim(); + } catch (Exception e) { + SLog.INSTANCE.w(TAG, "Error thrown by animation listener", e); + } + } + + int vxCenter = getPaddingLeft() + (getWidth() - getPaddingRight() - getPaddingLeft()) / 2; + int vyCenter = getPaddingTop() + (getHeight() - getPaddingBottom() - getPaddingTop()) / 2; + float targetScale = limitedScale(this.targetScale); + PointF targetSCenter = panLimited ? limitedSCenter(this.targetSCenter.x, this.targetSCenter.y, targetScale, new PointF()) : this.targetSCenter; + anim = new Anim(); + anim.scaleStart = scale; + anim.scaleEnd = targetScale; + anim.time = System.currentTimeMillis(); + anim.sCenterEndRequested = targetSCenter; + anim.sCenterStart = getCenter(); + anim.sCenterEnd = targetSCenter; + anim.vFocusStart = sourceToViewCoord(targetSCenter); + anim.vFocusEnd = new PointF( + vxCenter, + vyCenter + ); + anim.duration = duration; + anim.interruptible = interruptible; + anim.easing = easing; + anim.origin = origin; + anim.time = System.currentTimeMillis(); + anim.listener = listener; + + if (vFocus != null) { + // Calculate where translation will be at the end of the anim + float vTranslateXEnd = vFocus.x - (targetScale * anim.sCenterStart.x); + float vTranslateYEnd = vFocus.y - (targetScale * anim.sCenterStart.y); + ScaleAndTranslate satEnd = new ScaleAndTranslate(targetScale, new PointF(vTranslateXEnd, vTranslateYEnd)); + // Fit the end translation into bounds + fitToBounds(true, satEnd); + // Adjust the position of the focus point at end so image will be in bounds + anim.vFocusEnd = new PointF( + vFocus.x + (satEnd.vTranslate.x - vTranslateXEnd), + vFocus.y + (satEnd.vTranslate.y - vTranslateYEnd) + ); + } + + invalidate(); + } + + } + + +} \ No newline at end of file diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/CompatDecoderFactory.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/CompatDecoderFactory.java new file mode 100644 index 0000000..41f008f --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/CompatDecoderFactory.java @@ -0,0 +1,52 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * Compatibility factory to instantiate decoders with empty public constructors. + * + * @param <T> The base type of the decoder this factory will produce. + */ +@SuppressWarnings("WeakerAccess") +public class CompatDecoderFactory<T> implements DecoderFactory<T> { + + private final Class<? extends T> clazz; + private final Bitmap.Config bitmapConfig; + + /** + * Construct a factory for the given class. This must have a default constructor. + * + * @param clazz a class that implements {@link cc.shinichi.library.view.subsampling.decoder.ImageDecoder} or {@link cc.shinichi.library.view.subsampling.decoder.ImageRegionDecoder}. + */ + public CompatDecoderFactory(@NonNull Class<? extends T> clazz) { + this(clazz, null); + } + + /** + * Construct a factory for the given class. This must have a constructor that accepts a {@link Bitmap.Config} instance. + * + * @param clazz a class that implements {@link ImageDecoder} or {@link ImageRegionDecoder}. + * @param bitmapConfig bitmap configuration to be used when loading images. + */ + public CompatDecoderFactory(@NonNull Class<? extends T> clazz, Bitmap.Config bitmapConfig) { + this.clazz = clazz; + this.bitmapConfig = bitmapConfig; + } + + @Override + @NonNull + public T make() throws IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException { + if (bitmapConfig == null) { + return clazz.newInstance(); + } else { + Constructor<? extends T> ctor = clazz.getConstructor(Bitmap.Config.class); + return ctor.newInstance(bitmapConfig); + } + } + +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/DecoderFactory.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/DecoderFactory.java new file mode 100644 index 0000000..17560a5 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/DecoderFactory.java @@ -0,0 +1,26 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import androidx.annotation.NonNull; + +import java.lang.reflect.InvocationTargetException; + +/** + * Interface for {@link ImageDecoder} and {@link ImageRegionDecoder} factories. + * + * @param <T> the class of decoder that will be produced. + */ +public interface DecoderFactory<T> { + + /** + * Produce a new instance of a decoder with type {@link T}. + * + * @return a new instance of your decoder. + * @throws IllegalAccessException if the factory class cannot be instantiated. + * @throws InstantiationException if the factory class cannot be instantiated. + * @throws NoSuchMethodException if the factory class cannot be instantiated. + * @throws InvocationTargetException if the factory class cannot be instantiated. + */ + @NonNull + T make() throws IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException; + +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageDecoder.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageDecoder.java new file mode 100644 index 0000000..b9abc5c --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageDecoder.java @@ -0,0 +1,32 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; + +import androidx.annotation.NonNull; + +/** + * Interface for image decoding classes, allowing the default {@link android.graphics.BitmapFactory} + * based on the Skia library to be replaced with a custom class. + */ +public interface ImageDecoder { + + /** + * Decode an image. The URI can be in one of the following formats: + * <br> + * File: <code>file:///scard/picture.jpg</code> + * <br> + * Asset: <code>file:///android_asset/picture.png</code> + * <br> + * Resource: <code>android.resource://com.example.app/drawable/picture</code> + * + * @param context Application context + * @param uri URI of the image + * @return the decoded bitmap + * @throws Exception if decoding fails. + */ + @NonNull + Bitmap decode(Context context, @NonNull Uri uri) throws Exception; + +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageRegionDecoder.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageRegionDecoder.java new file mode 100644 index 0000000..7a2ba98 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/ImageRegionDecoder.java @@ -0,0 +1,67 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; + +import androidx.annotation.NonNull; + +/** + * Interface for image decoding classes, allowing the default {@link android.graphics.BitmapRegionDecoder} + * based on the Skia library to be replaced with a custom class. + */ +public interface ImageRegionDecoder { + + /** + * Initialise the decoder. When possible, perform initial setup work once in this method. The + * dimensions of the image must be returned. The URI can be in one of the following formats: + * <br> + * File: <code>file:///scard/picture.jpg</code> + * <br> + * Asset: <code>file:///android_asset/picture.png</code> + * <br> + * Resource: <code>android.resource://com.example.app/drawable/picture</code> + * + * @param context Application context. A reference may be held, but must be cleared on recycle. + * @param uri URI of the image. + * @return Dimensions of the image. + * @throws Exception if initialisation fails. + */ + @NonNull + Point init(Context context, @NonNull Uri uri) throws Exception; + + /** + * <p> + * Decode a region of the image with the given sample size. This method is called off the UI + * thread so it can safely load the image on the current thread. It is called from + * {@link android.os.AsyncTask}s running in an executor that may have multiple threads, so + * implementations must be thread safe. Adding <code>synchronized</code> to the method signature + * is the simplest way to achieve this, but bear in mind the {@link #recycle()} method can be + * called concurrently. + * </p><p> + * See {@link SkiaImageRegionDecoder} and {@link SkiaPooledImageRegionDecoder} for examples of + * internal locking and synchronization. + * </p> + * + * @param sRect Source image rectangle to decode. + * @param sampleSize Sample size. + * @return The decoded region. It is safe to return null if decoding fails. + */ + @NonNull + Bitmap decodeRegion(@NonNull Rect sRect, int sampleSize); + + /** + * Status check. Should return false before initialisation and after recycle. + * + * @return true if the decoder is ready to be used. + */ + boolean isReady(); + + /** + * This method will be called when the decoder is no longer required. It should clean up any resources still in use. + */ + void recycle(); + +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageDecoder.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageDecoder.java new file mode 100644 index 0000000..a253999 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageDecoder.java @@ -0,0 +1,108 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.InputStream; +import java.util.List; + +import cc.shinichi.library.view.subsampling.SubsamplingScaleImageView; + +/** + * Default implementation of {@link cc.shinichi.library.view.subsampling.decoder.ImageDecoder} + * using Android's {@link BitmapFactory}, based on the Skia library. This + * works well in most circumstances and has reasonable performance, however it has some problems + * with grayscale, indexed and CMYK images. + */ +public class SkiaImageDecoder implements ImageDecoder { + + private static final String FILE_PREFIX = "file://"; + private static final String ASSET_PREFIX = FILE_PREFIX + "/android_asset/"; + private static final String RESOURCE_PREFIX = ContentResolver.SCHEME_ANDROID_RESOURCE + "://"; + + private final Bitmap.Config bitmapConfig; + + @Keep + @SuppressWarnings("unused") + public SkiaImageDecoder() { + this(null); + } + + @SuppressWarnings({"WeakerAccess", "SameParameterValue"}) + public SkiaImageDecoder(@Nullable Bitmap.Config bitmapConfig) { + Bitmap.Config globalBitmapConfig = SubsamplingScaleImageView.getPreferredBitmapConfig(); + if (bitmapConfig != null) { + this.bitmapConfig = bitmapConfig; + } else if (globalBitmapConfig != null) { + this.bitmapConfig = globalBitmapConfig; + } else { + this.bitmapConfig = Bitmap.Config.RGB_565; + } + } + + @Override + @NonNull + public Bitmap decode(Context context, @NonNull Uri uri) throws Exception { + String uriString = uri.toString(); + BitmapFactory.Options options = new BitmapFactory.Options(); + Bitmap bitmap; + options.inPreferredConfig = bitmapConfig; + if (uriString.startsWith(RESOURCE_PREFIX)) { + Resources res; + String packageName = uri.getAuthority(); + if (context.getPackageName().equals(packageName)) { + res = context.getResources(); + } else { + PackageManager pm = context.getPackageManager(); + res = pm.getResourcesForApplication(packageName); + } + + int id = 0; + List<String> segments = uri.getPathSegments(); + int size = segments.size(); + if (size == 2 && segments.get(0).equals("drawable")) { + String resName = segments.get(1); + id = res.getIdentifier(resName, "drawable", packageName); + } else if (size == 1 && TextUtils.isDigitsOnly(segments.get(0))) { + try { + id = Integer.parseInt(segments.get(0)); + } catch (NumberFormatException ignored) { + } + } + + bitmap = BitmapFactory.decodeResource(context.getResources(), id, options); + } else if (uriString.startsWith(ASSET_PREFIX)) { + String assetName = uriString.substring(ASSET_PREFIX.length()); + bitmap = BitmapFactory.decodeStream(context.getAssets().open(assetName), null, options); + } else if (uriString.startsWith(FILE_PREFIX)) { + bitmap = BitmapFactory.decodeFile(uriString.substring(FILE_PREFIX.length()), options); + } else { + InputStream inputStream = null; + try { + ContentResolver contentResolver = context.getContentResolver(); + inputStream = contentResolver.openInputStream(uri); + bitmap = BitmapFactory.decodeStream(inputStream, null, options); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (Exception e) { /* Ignore */ } + } + } + } + if (bitmap == null) { + throw new RuntimeException("Skia image region decoder returned null bitmap - image format may not be supported"); + } + return bitmap; + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageRegionDecoder.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageRegionDecoder.java new file mode 100644 index 0000000..e5231fc --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaImageRegionDecoder.java @@ -0,0 +1,165 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.AssetManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.InputStream; +import java.util.List; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import cc.shinichi.library.view.subsampling.SubsamplingScaleImageView; + +/** + * Default implementation of {@link cc.shinichi.library.view.subsampling.decoder.ImageRegionDecoder} + * using Android's {@link BitmapRegionDecoder}, based on the Skia library. This + * works well in most circumstances and has reasonable performance due to the cached decoder instance, + * however it has some problems with grayscale, indexed and CMYK images. + * <p> + * A {@link ReadWriteLock} is used to delegate responsibility for multi threading behaviour to the + * {@link BitmapRegionDecoder} instance on SDK >= 21, whilst allowing this class to block until no + * tiles are being loaded before recycling the decoder. In practice, {@link BitmapRegionDecoder} is + * synchronized internally so this has no real impact on performance. + */ +public class SkiaImageRegionDecoder implements ImageRegionDecoder { + + private static final String FILE_PREFIX = "file://"; + private static final String ASSET_PREFIX = FILE_PREFIX + "/android_asset/"; + private static final String RESOURCE_PREFIX = ContentResolver.SCHEME_ANDROID_RESOURCE + "://"; + private final ReadWriteLock decoderLock = new ReentrantReadWriteLock(true); + private final Bitmap.Config bitmapConfig; + private BitmapRegionDecoder decoder; + + @Keep + @SuppressWarnings("unused") + public SkiaImageRegionDecoder() { + this(null); + } + + @SuppressWarnings({"WeakerAccess", "SameParameterValue"}) + public SkiaImageRegionDecoder(@Nullable Bitmap.Config bitmapConfig) { + Bitmap.Config globalBitmapConfig = SubsamplingScaleImageView.getPreferredBitmapConfig(); + if (bitmapConfig != null) { + this.bitmapConfig = bitmapConfig; + } else if (globalBitmapConfig != null) { + this.bitmapConfig = globalBitmapConfig; + } else { + this.bitmapConfig = Bitmap.Config.RGB_565; + } + } + + @Override + @NonNull + public Point init(Context context, @NonNull Uri uri) throws Exception { + String uriString = uri.toString(); + if (uriString.startsWith(RESOURCE_PREFIX)) { + Resources res; + String packageName = uri.getAuthority(); + if (context.getPackageName().equals(packageName)) { + res = context.getResources(); + } else { + PackageManager pm = context.getPackageManager(); + res = pm.getResourcesForApplication(packageName); + } + + int id = 0; + List<String> segments = uri.getPathSegments(); + int size = segments.size(); + if (size == 2 && segments.get(0).equals("drawable")) { + String resName = segments.get(1); + id = res.getIdentifier(resName, "drawable", packageName); + } else if (size == 1 && TextUtils.isDigitsOnly(segments.get(0))) { + try { + id = Integer.parseInt(segments.get(0)); + } catch (NumberFormatException ignored) { + } + } + + decoder = BitmapRegionDecoder.newInstance(context.getResources().openRawResource(id), false); + } else if (uriString.startsWith(ASSET_PREFIX)) { + String assetName = uriString.substring(ASSET_PREFIX.length()); + decoder = BitmapRegionDecoder.newInstance(context.getAssets().open(assetName, AssetManager.ACCESS_RANDOM), false); + } else if (uriString.startsWith(FILE_PREFIX)) { + decoder = BitmapRegionDecoder.newInstance(uriString.substring(FILE_PREFIX.length()), false); + } else { + InputStream inputStream = null; + try { + ContentResolver contentResolver = context.getContentResolver(); + inputStream = contentResolver.openInputStream(uri); + if (inputStream == null) { + throw new Exception("Content resolver returned null stream. Unable to initialise with uri."); + } + decoder = BitmapRegionDecoder.newInstance(inputStream, false); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (Exception e) { /* Ignore */ } + } + } + } + return new Point(decoder.getWidth(), decoder.getHeight()); + } + + @Override + @NonNull + public Bitmap decodeRegion(@NonNull Rect sRect, int sampleSize) { + getDecodeLock().lock(); + try { + if (decoder != null && !decoder.isRecycled()) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + options.inPreferredConfig = bitmapConfig; + Bitmap bitmap = decoder.decodeRegion(sRect, options); + if (bitmap == null) { + throw new RuntimeException("Skia image decoder returned null bitmap - image format may not be supported"); + } + return bitmap; + } else { + throw new IllegalStateException("Cannot decode region after decoder has been recycled"); + } + } finally { + getDecodeLock().unlock(); + } + } + + @Override + public synchronized boolean isReady() { + return decoder != null && !decoder.isRecycled(); + } + + @Override + public synchronized void recycle() { + decoderLock.writeLock().lock(); + try { + decoder.recycle(); + decoder = null; + } finally { + decoderLock.writeLock().unlock(); + } + } + + /** + * Before SDK 21, BitmapRegionDecoder was not synchronized internally. Any attempt to decode + * regions from multiple threads with one decoder instance causes a segfault. For old versions + * use the write lock to enforce single threaded decoding. + */ + private Lock getDecodeLock() { + return decoderLock.readLock(); + } +} diff --git a/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaPooledImageRegionDecoder.java b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaPooledImageRegionDecoder.java new file mode 100644 index 0000000..b3257e1 --- /dev/null +++ b/library/src/main/java/cc/shinichi/library/view/subsampling/decoder/SkiaPooledImageRegionDecoder.java @@ -0,0 +1,468 @@ +package cc.shinichi.library.view.subsampling.decoder; + +import static android.content.Context.ACTIVITY_SERVICE; + +import android.app.ActivityManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.AssetFileDescriptor; +import android.content.res.AssetManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileFilter; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.regex.Pattern; + +import cc.shinichi.library.tool.common.SLog; +import cc.shinichi.library.view.subsampling.SubsamplingScaleImageView; + +/** + * <p> + * An implementation of {@link cc.shinichi.library.view.subsampling.decoder.ImageRegionDecoder} using a pool of {@link BitmapRegionDecoder}s, + * to provide true parallel loading of tiles. This is only effective if parallel loading has been + * enabled in the view by calling {@link SubsamplingScaleImageView#setExecutor(Executor)} + * with a multi-threaded {@link Executor} instance. + * </p><p> + * One decoder is initialised when the class is initialised. This is enough to decode base layer tiles. + * Additional decoders are initialised when a subregion of the image is first requested, which indicates + * interaction with the view. Creation of additional encoders stops when {@link #allowAdditionalDecoder(int, long)} + * returns false. The default implementation takes into account the file size, number of CPU cores, + * low memory status and a hard limit of 4. Extend this class to customise this. + * </p><p> + * <b>WARNING:</b> This class is highly experimental and not proven to be stable on a wide range of + * devices. You are advised to test it thoroughly on all available devices, and code your app to use + * {@link SkiaImageRegionDecoder} on old or low powered devices you could not test. + * </p> + */ +public class SkiaPooledImageRegionDecoder implements ImageRegionDecoder { + + private static final String TAG = SkiaPooledImageRegionDecoder.class.getSimpleName(); + private static final String FILE_PREFIX = "file://"; + private static final String ASSET_PREFIX = FILE_PREFIX + "/android_asset/"; + private static final String RESOURCE_PREFIX = ContentResolver.SCHEME_ANDROID_RESOURCE + "://"; + private static boolean debug = false; + private final ReadWriteLock decoderLock = new ReentrantReadWriteLock(true); + private final Bitmap.Config bitmapConfig; + private final Point imageDimensions = new Point(0, 0); + private final AtomicBoolean lazyInited = new AtomicBoolean(false); + private DecoderPool decoderPool = new DecoderPool(); + private Context context; + private Uri uri; + private long fileLength = Long.MAX_VALUE; + + @Keep + @SuppressWarnings("unused") + public SkiaPooledImageRegionDecoder() { + this(null); + } + + @SuppressWarnings({"WeakerAccess", "SameParameterValue"}) + public SkiaPooledImageRegionDecoder(@Nullable Bitmap.Config bitmapConfig) { + Bitmap.Config globalBitmapConfig = SubsamplingScaleImageView.getPreferredBitmapConfig(); + if (bitmapConfig != null) { + this.bitmapConfig = bitmapConfig; + } else if (globalBitmapConfig != null) { + this.bitmapConfig = globalBitmapConfig; + } else { + this.bitmapConfig = Bitmap.Config.RGB_565; + } + } + + /** + * Controls logging of debug messages. All instances are affected. + * + * @param debug true to enable debug logging, false to disable. + */ + @Keep + @SuppressWarnings("unused") + public static void setDebug(boolean debug) { + SkiaPooledImageRegionDecoder.debug = debug; + } + + /** + * Initialises the decoder pool. This method creates one decoder on the current thread and uses + * it to decode the bounds, then spawns an independent thread to populate the pool with an + * additional three decoders. The thread will abort if {@link #recycle()} is called. + */ + @Override + @NonNull + public Point init(final Context context, @NonNull final Uri uri) throws Exception { + this.context = context; + this.uri = uri; + initialiseDecoder(); + return this.imageDimensions; + } + + /** + * Initialises extra decoders for as long as {@link #allowAdditionalDecoder(int, long)} returns + * true and the pool has not been recycled. + */ + private void lazyInit() { + if (lazyInited.compareAndSet(false, true) && fileLength < Long.MAX_VALUE) { + debug("Starting lazy init of additional decoders"); + Thread thread = new Thread() { + @Override + public void run() { + while (decoderPool != null && allowAdditionalDecoder(decoderPool.size(), fileLength)) { + // New decoders can be created while reading tiles but this read lock prevents + // them being initialised while the pool is being recycled. + try { + if (decoderPool != null) { + long start = System.currentTimeMillis(); + debug("Starting decoder"); + initialiseDecoder(); + long end = System.currentTimeMillis(); + debug("Started decoder, took " + (end - start) + "ms"); + } + } catch (Exception e) { + // A decoder has already been successfully created so we can ignore this + debug("Failed to start decoder: " + e.getMessage()); + } + } + } + }; + thread.start(); + } + } + + /** + * Initialises a new {@link BitmapRegionDecoder} and adds it to the pool, unless the pool has + * been recycled while it was created. + */ + private void initialiseDecoder() throws Exception { + String uriString = uri.toString(); + BitmapRegionDecoder decoder; + long fileLength = Long.MAX_VALUE; + if (uriString.startsWith(RESOURCE_PREFIX)) { + Resources res; + String packageName = uri.getAuthority(); + if (context.getPackageName().equals(packageName)) { + res = context.getResources(); + } else { + PackageManager pm = context.getPackageManager(); + res = pm.getResourcesForApplication(packageName); + } + + int id = 0; + List<String> segments = uri.getPathSegments(); + int size = segments.size(); + if (size == 2 && segments.get(0).equals("drawable")) { + String resName = segments.get(1); + id = res.getIdentifier(resName, "drawable", packageName); + } else if (size == 1 && TextUtils.isDigitsOnly(segments.get(0))) { + try { + id = Integer.parseInt(segments.get(0)); + } catch (NumberFormatException ignored) { + } + } + try { + AssetFileDescriptor descriptor = context.getResources().openRawResourceFd(id); + fileLength = descriptor.getLength(); + } catch (Exception e) { + // Pooling disabled + } + decoder = BitmapRegionDecoder.newInstance(context.getResources().openRawResource(id), false); + } else if (uriString.startsWith(ASSET_PREFIX)) { + String assetName = uriString.substring(ASSET_PREFIX.length()); + try { + AssetFileDescriptor descriptor = context.getAssets().openFd(assetName); + fileLength = descriptor.getLength(); + } catch (Exception e) { + // Pooling disabled + } + decoder = BitmapRegionDecoder.newInstance(context.getAssets().open(assetName, AssetManager.ACCESS_RANDOM), false); + } else if (uriString.startsWith(FILE_PREFIX)) { + decoder = BitmapRegionDecoder.newInstance(uriString.substring(FILE_PREFIX.length()), false); + try { + File file = new File(uriString); + if (file.exists()) { + fileLength = file.length(); + } + } catch (Exception e) { + // Pooling disabled + } + } else { + InputStream inputStream = null; + try { + ContentResolver contentResolver = context.getContentResolver(); + inputStream = contentResolver.openInputStream(uri); + decoder = BitmapRegionDecoder.newInstance(inputStream, false); + try { + AssetFileDescriptor descriptor = contentResolver.openAssetFileDescriptor(uri, "r"); + if (descriptor != null) { + fileLength = descriptor.getLength(); + } + } catch (Exception e) { + // Stick with MAX_LENGTH + } + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (Exception e) { /* Ignore */ } + } + } + } + + this.fileLength = fileLength; + this.imageDimensions.set(decoder.getWidth(), decoder.getHeight()); + decoderLock.writeLock().lock(); + try { + if (decoderPool != null) { + decoderPool.add(decoder); + } + } finally { + decoderLock.writeLock().unlock(); + } + } + + /** + * Acquire a read lock to prevent decoding overlapping with recycling, then check the pool still + * exists and acquire a decoder to load the requested region. There is no check whether the pool + * currently has decoders, because it's guaranteed to have one decoder after {@link #init(Context, Uri)} + * is called and be null once {@link #recycle()} is called. In practice the view can't call this + * method until after {@link #init(Context, Uri)}, so there will be no blocking on an empty pool. + */ + @Override + @NonNull + public Bitmap decodeRegion(@NonNull Rect sRect, int sampleSize) { + debug("Decode region " + sRect + " on thread " + Thread.currentThread().getName()); + if (sRect.width() < imageDimensions.x || sRect.height() < imageDimensions.y) { + lazyInit(); + } + decoderLock.readLock().lock(); + try { + if (decoderPool != null) { + BitmapRegionDecoder decoder = decoderPool.acquire(); + try { + // Decoder can't be null or recycled in practice + if (decoder != null && !decoder.isRecycled()) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + options.inPreferredConfig = bitmapConfig; + Bitmap bitmap = decoder.decodeRegion(sRect, options); + if (bitmap == null) { + throw new RuntimeException("Skia image decoder returned null bitmap - image format may not be supported"); + } + return bitmap; + } + } finally { + if (decoder != null) { + decoderPool.release(decoder); + } + } + } + throw new IllegalStateException("Cannot decode region after decoder has been recycled"); + } finally { + decoderLock.readLock().unlock(); + } + } + + /** + * Holding a read lock to avoid returning true while the pool is being recycled, this returns + * true if the pool has at least one decoder available. + */ + @Override + public synchronized boolean isReady() { + return decoderPool != null && !decoderPool.isEmpty(); + } + + /** + * Wait until all read locks held by {@link #decodeRegion(Rect, int)} are released, then recycle + * and destroy the pool. Elsewhere, when a read lock is acquired, we must check the pool is not null. + */ + @Override + public synchronized void recycle() { + decoderLock.writeLock().lock(); + try { + if (decoderPool != null) { + decoderPool.recycle(); + decoderPool = null; + context = null; + uri = null; + } + } finally { + decoderLock.writeLock().unlock(); + } + } + + /** + * Called before creating a new decoder. Based on number of CPU cores, available memory, and the + * size of the image file, determines whether another decoder can be created. Subclasses can + * override and customise this. + * + * @param numberOfDecoders the number of decoders that have been created so far + * @param fileLength the size of the image file in bytes. Creating another decoder will use approximately this much native memory. + * @return true if another decoder can be created. + */ + @SuppressWarnings("WeakerAccess") + protected boolean allowAdditionalDecoder(int numberOfDecoders, long fileLength) { + if (numberOfDecoders >= 4) { + debug("No additional decoders allowed, reached hard limit (4)"); + return false; + } else if (numberOfDecoders * fileLength > 20 * 1024 * 1024) { + debug("No additional encoders allowed, reached hard memory limit (20Mb)"); + return false; + } else if (numberOfDecoders >= getNumberOfCores()) { + debug("No additional encoders allowed, limited by CPU cores (" + getNumberOfCores() + ")"); + return false; + } else if (isLowMemory()) { + debug("No additional encoders allowed, memory is low"); + return false; + } + debug("Additional decoder allowed, current count is " + numberOfDecoders + ", estimated native memory " + ((fileLength * numberOfDecoders) / (1024 * 1024)) + "Mb"); + return true; + } + + private int getNumberOfCores() { + return Runtime.getRuntime().availableProcessors(); + } + + /** + * Gets the number of cores available in this device, across all processors. + * Requires: Ability to peruse the filesystem at "/sys/devices/system/cpu" + * + * @return The number of cores, or 1 if failed to get result + */ + private int getNumCoresOldPhones() { + class CpuFilter implements FileFilter { + @Override + public boolean accept(File pathname) { + return Pattern.matches("cpu[0-9]+", pathname.getName()); + } + } + try { + File dir = new File("/sys/devices/system/cpu/"); + File[] files = dir.listFiles(new CpuFilter()); + return files.length; + } catch (Exception e) { + return 1; + } + } + + private boolean isLowMemory() { + ActivityManager activityManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); + if (activityManager != null) { + ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); + activityManager.getMemoryInfo(memoryInfo); + return memoryInfo.lowMemory; + } else { + return true; + } + } + + private void debug(String message) { + if (debug) { + SLog.INSTANCE.d(TAG, message); + } + } + + /** + * A simple pool of {@link BitmapRegionDecoder} instances, all loading from the same source. + */ + private static class DecoderPool { + private final Semaphore available = new Semaphore(0, true); + private final Map<BitmapRegionDecoder, Boolean> decoders = new ConcurrentHashMap<>(); + + /** + * Returns false if there is at least one decoder in the pool. + */ + private synchronized boolean isEmpty() { + return decoders.isEmpty(); + } + + /** + * Returns number of encoders. + */ + private synchronized int size() { + return decoders.size(); + } + + /** + * Acquire a decoder. Blocks until one is available. + */ + private BitmapRegionDecoder acquire() { + available.acquireUninterruptibly(); + return getNextAvailable(); + } + + /** + * Release a decoder back to the pool. + */ + private void release(BitmapRegionDecoder decoder) { + if (markAsUnused(decoder)) { + available.release(); + } + } + + /** + * Adds a newly created decoder to the pool, releasing an additional permit. + */ + private synchronized void add(BitmapRegionDecoder decoder) { + decoders.put(decoder, false); + available.release(); + } + + /** + * While there are decoders in the map, wait until each is available before acquiring, + * recycling and removing it. After this is called, any call to {@link #acquire()} will + * block forever, so this call should happen within a write lock, and all calls to + * {@link #acquire()} should be made within a read lock so they cannot end up blocking on + * the semaphore when it has no permits. + */ + private synchronized void recycle() { + while (!decoders.isEmpty()) { + BitmapRegionDecoder decoder = acquire(); + decoder.recycle(); + decoders.remove(decoder); + } + } + + private synchronized BitmapRegionDecoder getNextAvailable() { + for (Map.Entry<BitmapRegionDecoder, Boolean> entry : decoders.entrySet()) { + if (!entry.getValue()) { + entry.setValue(true); + return entry.getKey(); + } + } + return null; + } + + private synchronized boolean markAsUnused(BitmapRegionDecoder decoder) { + for (Map.Entry<BitmapRegionDecoder, Boolean> entry : decoders.entrySet()) { + if (decoder == entry.getKey()) { + if (entry.getValue()) { + entry.setValue(false); + return true; + } else { + return false; + } + } + } + return false; + } + + } + +} diff --git a/library/src/main/res/anim/fade_in.xml b/library/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..6fca88a --- /dev/null +++ b/library/src/main/res/anim/fade_in.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8" standalone="no"?> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:interpolator="@android:anim/decelerate_interpolator"> + <alpha + android:duration="300" + android:fromAlpha="0.0" + android:toAlpha="1.0" /> +</set> \ No newline at end of file diff --git a/library/src/main/res/anim/fade_out.xml b/library/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..a6dd6a2 --- /dev/null +++ b/library/src/main/res/anim/fade_out.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8" standalone="no"?> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:interpolator="@android:anim/decelerate_interpolator"> + <alpha + android:duration="300" + android:fromAlpha="1.0" + android:toAlpha="0.0" /> +</set> \ No newline at end of file diff --git a/library/src/main/res/anim/scale_in.xml b/library/src/main/res/anim/scale_in.xml new file mode 100644 index 0000000..7a48b3b --- /dev/null +++ b/library/src/main/res/anim/scale_in.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:interpolator="@android:interpolator/decelerate_quad"> + <scale + android:duration="300" + android:fromXScale="0.5" + android:fromYScale="0.5" + android:pivotX="50%" + android:pivotY="50%" + android:toXScale="1.0" + android:toYScale="1.0" /> + <alpha + android:duration="300" + android:fromAlpha="0.0" + android:toAlpha="1.0" /> +</set> \ No newline at end of file diff --git a/library/src/main/res/anim/scale_out.xml b/library/src/main/res/anim/scale_out.xml new file mode 100644 index 0000000..6f2d0c0 --- /dev/null +++ b/library/src/main/res/anim/scale_out.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:interpolator="@android:interpolator/accelerate_quad"> + <scale + android:duration="300" + android:fromXScale="1.0" + android:fromYScale="1.0" + android:pivotX="50%" + android:pivotY="50%" + android:toXScale="0.5" + android:toYScale="0.5" /> + <alpha + android:duration="300" + android:fromAlpha="1.0" + android:toAlpha="0.0" /> +</set> \ No newline at end of file diff --git a/library/src/main/res/drawable-xhdpi/ic_action_close.png b/library/src/main/res/drawable-xhdpi/ic_action_close.png new file mode 100644 index 0000000..93e294a --- /dev/null +++ b/library/src/main/res/drawable-xhdpi/ic_action_close.png Binary files differ diff --git a/library/src/main/res/drawable-xhdpi/icon_change_orientation.png b/library/src/main/res/drawable-xhdpi/icon_change_orientation.png new file mode 100644 index 0000000..76cc08b --- /dev/null +++ b/library/src/main/res/drawable-xhdpi/icon_change_orientation.png Binary files differ diff --git a/library/src/main/res/drawable-xhdpi/icon_download_new.png b/library/src/main/res/drawable-xhdpi/icon_download_new.png new file mode 100644 index 0000000..7cd7622 --- /dev/null +++ b/library/src/main/res/drawable-xhdpi/icon_download_new.png Binary files differ diff --git a/library/src/main/res/drawable-xhdpi/icon_video_play.png b/library/src/main/res/drawable-xhdpi/icon_video_play.png new file mode 100644 index 0000000..8a7b145 --- /dev/null +++ b/library/src/main/res/drawable-xhdpi/icon_video_play.png Binary files differ diff --git a/library/src/main/res/drawable-xhdpi/icon_video_stop.png b/library/src/main/res/drawable-xhdpi/icon_video_stop.png new file mode 100644 index 0000000..f2fb141 --- /dev/null +++ b/library/src/main/res/drawable-xhdpi/icon_video_stop.png Binary files differ diff --git a/library/src/main/res/drawable-xhdpi/load_failed.png b/library/src/main/res/drawable-xhdpi/load_failed.png new file mode 100644 index 0000000..29eb760 --- /dev/null +++ b/library/src/main/res/drawable-xhdpi/load_failed.png Binary files differ diff --git a/library/src/main/res/drawable-xxhdpi/ic_action_close.png b/library/src/main/res/drawable-xxhdpi/ic_action_close.png new file mode 100644 index 0000000..3ea0130 --- /dev/null +++ b/library/src/main/res/drawable-xxhdpi/ic_action_close.png Binary files differ diff --git a/library/src/main/res/drawable-xxhdpi/icon_change_orientation.png b/library/src/main/res/drawable-xxhdpi/icon_change_orientation.png new file mode 100644 index 0000000..a0765a3 --- /dev/null +++ b/library/src/main/res/drawable-xxhdpi/icon_change_orientation.png Binary files differ diff --git a/library/src/main/res/drawable-xxhdpi/icon_download_new.png b/library/src/main/res/drawable-xxhdpi/icon_download_new.png new file mode 100644 index 0000000..94eb166 --- /dev/null +++ b/library/src/main/res/drawable-xxhdpi/icon_download_new.png Binary files differ diff --git a/library/src/main/res/drawable-xxhdpi/icon_video_play.png b/library/src/main/res/drawable-xxhdpi/icon_video_play.png new file mode 100644 index 0000000..432dfc0 --- /dev/null +++ b/library/src/main/res/drawable-xxhdpi/icon_video_play.png Binary files differ diff --git a/library/src/main/res/drawable-xxhdpi/icon_video_stop.png b/library/src/main/res/drawable-xxhdpi/icon_video_stop.png new file mode 100644 index 0000000..4093625 --- /dev/null +++ b/library/src/main/res/drawable-xxhdpi/icon_video_stop.png Binary files differ diff --git a/library/src/main/res/drawable-xxhdpi/load_failed.png b/library/src/main/res/drawable-xxhdpi/load_failed.png new file mode 100644 index 0000000..ccdbbf2 --- /dev/null +++ b/library/src/main/res/drawable-xxhdpi/load_failed.png Binary files differ diff --git a/library/src/main/res/drawable/gray_circle_bg.xml b/library/src/main/res/drawable/gray_circle_bg.xml new file mode 100644 index 0000000..18156a3 --- /dev/null +++ b/library/src/main/res/drawable/gray_circle_bg.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="#4C000000" /> + <corners android:radius="40dp" /> +</shape> diff --git a/library/src/main/res/drawable/gray_square_circle_bg_white_stroke.xml b/library/src/main/res/drawable/gray_square_circle_bg_white_stroke.xml new file mode 100644 index 0000000..6e83e4e --- /dev/null +++ b/library/src/main/res/drawable/gray_square_circle_bg_white_stroke.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="#4C000000" /> + <stroke + android:width="1dp" + android:color="#ffffff" /> + <corners android:radius="4dp" /> +</shape> \ No newline at end of file diff --git a/library/src/main/res/drawable/shape_indicator_bg.xml b/library/src/main/res/drawable/shape_indicator_bg.xml new file mode 100644 index 0000000..6708319 --- /dev/null +++ b/library/src/main/res/drawable/shape_indicator_bg.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="#4C000000" /> + <corners android:radius="5dp" /> + <padding + android:left="10dp" + android:right="10dp" /> +</shape> \ No newline at end of file diff --git a/library/src/main/res/layout/sh_default_progress_layout.xml b/library/src/main/res/layout/sh_default_progress_layout.xml new file mode 100644 index 0000000..06cade9 --- /dev/null +++ b/library/src/main/res/layout/sh_default_progress_layout.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + + <ProgressBar + android:id="@id/sh_progress_view" + android:layout_width="70dp" + android:layout_height="70dp" + android:indeterminateTint="#ffffff" + android:indeterminateTintMode="src_in" /> + + <TextView + android:id="@id/sh_progress_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:text="100%" + android:textColor="#ffffff" + android:textSize="13sp" /> + +</RelativeLayout> \ No newline at end of file diff --git a/library/src/main/res/layout/sh_item_photoview.xml b/library/src/main/res/layout/sh_item_photoview.xml new file mode 100644 index 0000000..1eeffcf --- /dev/null +++ b/library/src/main/res/layout/sh_item_photoview.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout 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"> + + <cc.shinichi.library.view.helper.DragCloseView + android:id="@+id/fingerDragHelper" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <!--鍥剧墖--> + <cc.shinichi.library.view.subsampling.SubsamplingScaleImageView + android:id="@+id/static_view" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + <!--鍔ㄥ浘--> + <cc.shinichi.library.view.photoview.PhotoView + android:id="@+id/anim_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scaleType="centerInside" + android:visibility="gone" /> + + <!--瑙嗛--> + <androidx.media3.ui.PlayerView + android:id="@+id/video_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:show_timeout="1500" + app:controller_layout_id="@layout/sh_media_controller" /> + + </cc.shinichi.library.view.helper.DragCloseView> + + <ProgressBar + android:id="@+id/progress_view" + android:layout_width="30dp" + android:layout_height="30dp" + android:layout_gravity="center" + android:indeterminateTint="#ffffff" + android:indeterminateTintMode="src_in" /> + +</FrameLayout> \ No newline at end of file diff --git a/library/src/main/res/layout/sh_layout_preview.xml b/library/src/main/res/layout/sh_layout_preview.xml new file mode 100644 index 0000000..ee254e7 --- /dev/null +++ b/library/src/main/res/layout/sh_layout_preview.xml @@ -0,0 +1,107 @@ +<?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" + android:id="@+id/rootView" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="#000000"> + + <cc.shinichi.library.view.HackyViewPager + android:id="@+id/viewPager" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + <FrameLayout + android:id="@+id/fm_center_progress_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + </FrameLayout> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/consControllerOverlay" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <TextView + android:id="@+id/tv_indicator" + android:layout_width="wrap_content" + android:layout_height="20dp" + android:layout_marginTop="20dp" + android:background="@drawable/shape_indicator_bg" + android:gravity="center" + android:includeFontPadding="false" + android:text="1/9" + android:textColor="#ffffff" + android:textSize="15sp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/consBottomController" + android:layout_width="match_parent" + android:layout_height="60dp" + android:layout_alignParentStart="true" + android:layout_alignParentBottom="true" + android:orientation="horizontal" + app:layout_constraintBottom_toBottomOf="parent"> + + <androidx.appcompat.widget.AppCompatImageView + android:id="@+id/imgCloseButton" + android:layout_width="40dp" + android:layout_height="40dp" + android:layout_marginStart="10dp" + android:padding="8dp" + android:scaleType="centerInside" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_action_close" /> + + <FrameLayout + android:id="@+id/fm_image_show_origin_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <Button + android:id="@+id/btn_show_origin" + android:layout_width="wrap_content" + android:layout_height="35dp" + android:background="@drawable/gray_square_circle_bg_white_stroke" + android:gravity="center" + android:includeFontPadding="false" + android:paddingHorizontal="10dp" + android:text="@string/btn_original" + android:textAllCaps="false" + android:textColor="#ffffff" + android:textSize="12sp" /> + + </FrameLayout> + + <androidx.appcompat.widget.AppCompatImageView + android:id="@+id/img_download" + android:layout_width="40dp" + android:layout_height="40dp" + android:layout_marginEnd="10dp" + android:padding="8dp" + android:scaleType="centerInside" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/icon_download_new" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + </androidx.constraintlayout.widget.ConstraintLayout> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/library/src/main/res/layout/sh_media_controller.xml b/library/src/main/res/layout/sh_media_controller.xml new file mode 100644 index 0000000..eab4288 --- /dev/null +++ b/library/src/main/res/layout/sh_media_controller.xml @@ -0,0 +1,51 @@ +<?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" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="#1A000000" + android:orientation="horizontal"> + + <androidx.appcompat.widget.AppCompatImageView + android:id="@+id/ivPlayButton" + android:layout_width="60dp" + android:layout_height="60dp" + android:scaleType="centerCrop" + android:src="@drawable/icon_video_play" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <LinearLayout + android:id="@+id/llControllerContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="10dp" + android:paddingVertical="8dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> + + <SeekBar + android:id="@+id/seekbar" + style="@style/Widget.AppCompat.SeekBar" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:progressTint="#ffffff" + android:thumbTint="#ffffff" /> + + <TextView + android:id="@+id/tvPlayTime" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="00:00/00:00" + android:textColor="#ffffff" + android:textSize="12sp" /> + + </LinearLayout> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/library/src/main/res/values-en-rUS/strings.xml b/library/src/main/res/values-en-rUS/strings.xml new file mode 100644 index 0000000..f6057ff --- /dev/null +++ b/library/src/main/res/values-en-rUS/strings.xml @@ -0,0 +1,10 @@ +<resources> + <string name="indicator">%1$s/%2$s</string> + <string name="btn_original">Original image</string> + + <string name="toast_start_download">Download start</string> + <string name="toast_load_failed">Load failed</string> + <string name="toast_save_failed">Save failed</string> + <string name="toast_save_success">Saved to锛�%1$s</string> + <string name="toast_deny_permission_save_failed">You denied the storage permission and the download failed!</string> +</resources> \ No newline at end of file diff --git a/library/src/main/res/values/attrs.xml b/library/src/main/res/values/attrs.xml new file mode 100644 index 0000000..6945342 --- /dev/null +++ b/library/src/main/res/values/attrs.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <declare-styleable name="SubsamplingScaleImageView"> + <attr name="src" format="reference" /> + <attr name="assetName" format="string" /> + <attr name="panEnabled" format="boolean" /> + <attr name="zoomEnabled" format="boolean" /> + <attr name="quickScaleEnabled" format="boolean" /> + <attr name="tileBackgroundColor" format="color" /> + </declare-styleable> + +</resources> \ No newline at end of file diff --git a/library/src/main/res/values/ids.xml b/library/src/main/res/values/ids.xml new file mode 100644 index 0000000..6ae86c8 --- /dev/null +++ b/library/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <item name="sh_progress_view" type="id" /> + <item name="sh_progress_text" type="id" /> +</resources> \ No newline at end of file diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml new file mode 100644 index 0000000..fbbaabe --- /dev/null +++ b/library/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ +<resources> + <string name="indicator">%1$s/%2$s</string> + <string name="btn_original">鏌ョ湅鍘熷浘</string> + + <string name="toast_start_download">寮�濮嬩笅杞�</string> + <string name="toast_load_failed">鍔犺浇澶辫触</string> + <string name="toast_save_failed">淇濆瓨澶辫触</string> + <string name="toast_save_success">鎴愬姛淇濆瓨鍒帮細%1$s</string> + <string name="toast_deny_permission_save_failed">鎮ㄦ嫆缁濅簡瀛樺偍鏉冮檺锛屼笅杞藉け璐ワ紒</string> +</resources> \ No newline at end of file diff --git a/library/src/main/res/values/style.xml b/library/src/main/res/values/style.xml new file mode 100644 index 0000000..eefbbb0 --- /dev/null +++ b/library/src/main/res/values/style.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <style name="Theme.ImagePreview" parent="Theme.AppCompat.NoActionBar"> + <item name="android:windowFullscreen">false</item> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowIsTranslucent">true</item> + </style> + +</resources> \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 82a9a97..861322c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,4 @@ rootProject.name = "pipIrrApp" include ':app' -include ':mylibrary' include ':expand_button' +include ':library' -- Gitblit v1.8.0