Repository: NFLeo/Matisse-Kotlin Branch: master Commit: 0187da2d10f5 Files: 197 Total size: 797.4 KB Directory structure: gitextract_to5l3p13/ ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── leo/ │ │ └── matisse/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── leo/ │ │ │ └── matisse/ │ │ │ ├── ExampleActivity.kt │ │ │ ├── FrescoEngine.kt │ │ │ ├── Glide4Engine.kt │ │ │ ├── GlideEngine.kt │ │ │ ├── ImageSizeFilter.kt │ │ │ ├── MainActivity.kt │ │ │ └── SizeFilter.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_launcher_background.xml │ │ ├── drawable-v24/ │ │ │ └── ic_launcher_foreground.xml │ │ ├── drawable-xhdpi/ │ │ │ └── ic_launcher_foreground.xml │ │ ├── layout/ │ │ │ ├── activity_example.xml │ │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── colors_dracula.xml │ │ │ ├── ids.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ └── xml/ │ │ └── file_paths_public.xml │ └── test/ │ └── java/ │ └── com/ │ └── leo/ │ └── matisse/ │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradlew ├── gradlew.bat ├── matisse/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── matisse/ │ │ └── ExampleInstrumentedTest.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── matisse/ │ │ │ ├── Matisse.kt │ │ │ ├── MimeType.kt │ │ │ ├── MimeTypeManager.kt │ │ │ ├── SelectionCreator.kt │ │ │ ├── engine/ │ │ │ │ └── ImageEngine.kt │ │ │ ├── entity/ │ │ │ │ ├── Album.kt │ │ │ │ ├── CaptureStrategy.kt │ │ │ │ ├── ConstValue.kt │ │ │ │ ├── IncapableCause.kt │ │ │ │ └── Item.kt │ │ │ ├── filter/ │ │ │ │ └── Filter.kt │ │ │ ├── internal/ │ │ │ │ └── entity/ │ │ │ │ └── SelectionSpec.kt │ │ │ ├── listener/ │ │ │ │ ├── OnCheckedListener.kt │ │ │ │ └── OnSelectedListener.kt │ │ │ ├── loader/ │ │ │ │ ├── AlbumLoader.kt │ │ │ │ └── AlbumMediaLoader.kt │ │ │ ├── model/ │ │ │ │ ├── AlbumCallbacks.kt │ │ │ │ ├── AlbumCollection.kt │ │ │ │ ├── AlbumMediaCollection.kt │ │ │ │ └── SelectedItemCollection.kt │ │ │ ├── photoview/ │ │ │ │ ├── Compat.java │ │ │ │ ├── CustomGestureDetector.java │ │ │ │ ├── OnGestureListener.java │ │ │ │ ├── OnMatrixChangedListener.java │ │ │ │ ├── OnOutsidePhotoTapListener.java │ │ │ │ ├── OnPhotoTapListener.java │ │ │ │ ├── OnScaleChangedListener.java │ │ │ │ ├── OnSingleFlingListener.java │ │ │ │ ├── OnViewDragListener.java │ │ │ │ ├── OnViewTapListener.java │ │ │ │ ├── PhotoView.java │ │ │ │ ├── PhotoViewAttacher.java │ │ │ │ └── Util.java │ │ │ ├── ucrop/ │ │ │ │ ├── PictureMultiCuttingActivity.java │ │ │ │ ├── PicturePhotoGalleryAdapter.java │ │ │ │ ├── UCrop.java │ │ │ │ ├── UCropActivity.java │ │ │ │ ├── UCropMulti.java │ │ │ │ ├── callback/ │ │ │ │ │ └── Callback.kt │ │ │ │ ├── immersion/ │ │ │ │ │ ├── CropImmersiveManage.java │ │ │ │ │ ├── CropLightStatusBarUtils.java │ │ │ │ │ └── CropRomUtils.java │ │ │ │ ├── model/ │ │ │ │ │ ├── AspectRatio.java │ │ │ │ │ ├── CropParameters.java │ │ │ │ │ ├── CutInfo.java │ │ │ │ │ ├── ExifInfo.java │ │ │ │ │ └── ImageState.java │ │ │ │ ├── task/ │ │ │ │ │ ├── BitmapCropTask.java │ │ │ │ │ ├── BitmapLoadShowTask.java │ │ │ │ │ └── BitmapLoadTask.java │ │ │ │ ├── util/ │ │ │ │ │ ├── BitmapLoadUtils.java │ │ │ │ │ ├── CubicEasing.java │ │ │ │ │ ├── EglUtils.java │ │ │ │ │ ├── FastBitmapDrawable.java │ │ │ │ │ ├── FileUtils.java │ │ │ │ │ ├── ImageHeaderParser.java │ │ │ │ │ ├── RectUtils.java │ │ │ │ │ ├── RotationGestureDetector.java │ │ │ │ │ ├── SelectedStateListDrawable.java │ │ │ │ │ └── VersionUtils.java │ │ │ │ └── view/ │ │ │ │ ├── CropImageView.java │ │ │ │ ├── GestureCropImageView.java │ │ │ │ ├── OverlayView.java │ │ │ │ ├── TransformImageView.java │ │ │ │ ├── UCropView.java │ │ │ │ └── widget/ │ │ │ │ ├── AspectRatioTextView.java │ │ │ │ └── HorizontalProgressWheelView.java │ │ │ ├── ui/ │ │ │ │ ├── activity/ │ │ │ │ │ ├── AlbumPreviewActivity.kt │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ ├── BasePreviewActivity.kt │ │ │ │ │ ├── SelectedPreviewActivity.kt │ │ │ │ │ └── matisse/ │ │ │ │ │ ├── AlbumFolderSheetHelper.kt │ │ │ │ │ ├── AlbumLoadHelper.kt │ │ │ │ │ ├── IAlbumLoad.kt │ │ │ │ │ └── MatisseActivity.kt │ │ │ │ ├── adapter/ │ │ │ │ │ ├── AlbumMediaAdapter.kt │ │ │ │ │ ├── FolderItemMediaAdapter.kt │ │ │ │ │ ├── PicturePreviewPagerAdapter.kt │ │ │ │ │ ├── PreviewPagerAdapter.kt │ │ │ │ │ └── RecyclerViewCursorAdapter.kt │ │ │ │ └── view/ │ │ │ │ ├── BottomSheetDialogFragment.kt │ │ │ │ ├── FolderBottomSheet.kt │ │ │ │ ├── MediaSelectionFragment.kt │ │ │ │ ├── PicturePreviewItemFragment.kt │ │ │ │ └── PreviewItemFragment.kt │ │ │ ├── utils/ │ │ │ │ ├── BitmapUtils.kt │ │ │ │ ├── ExifInterfaceCompat.kt │ │ │ │ ├── IntentUtils.kt │ │ │ │ ├── ItemSelectUtils.kt │ │ │ │ ├── MediaStoreCompat.kt │ │ │ │ ├── PathUtils.kt │ │ │ │ ├── PhotoMetadataUtils.kt │ │ │ │ ├── Platform.kt │ │ │ │ └── UIUtils.kt │ │ │ └── widget/ │ │ │ ├── CheckRadioView.kt │ │ │ ├── CheckView.kt │ │ │ ├── IncapableDialog.kt │ │ │ ├── MediaGrid.kt │ │ │ ├── MediaGridInset.kt │ │ │ ├── PreviewViewPager.kt │ │ │ ├── SquareFrameLayout.kt │ │ │ └── longimage/ │ │ │ ├── CompatDecoderFactory.java │ │ │ ├── DecoderFactory.java │ │ │ ├── ImageDecoder.java │ │ │ ├── ImageRegionDecoder.java │ │ │ ├── ImageSource.java │ │ │ ├── ImageViewState.java │ │ │ ├── SkiaImageDecoder.java │ │ │ ├── SkiaImageRegionDecoder.java │ │ │ └── SubsamplingScaleImageView.java │ │ └── res/ │ │ ├── anim/ │ │ │ ├── bottom_down_out.xml │ │ │ ├── bottom_up_in.xml │ │ │ ├── ucrop_anim_fade_in.xml │ │ │ ├── ucrop_close.xml │ │ │ ├── ucrop_loader_circle_path.xml │ │ │ └── ucrop_loader_circle_scale.xml │ │ ├── color/ │ │ │ ├── selector_base_text.xml │ │ │ ├── selector_black_text.xml │ │ │ ├── selector_white_text.xml │ │ │ └── ucrop_scale_text_view_selector.xml │ │ ├── drawable/ │ │ │ ├── ucrop_gif_bg.xml │ │ │ ├── ucrop_oval_true.xml │ │ │ ├── ucrop_shadow_upside.xml │ │ │ ├── ucrop_vector_ic_crop.xml │ │ │ ├── ucrop_vector_loader.xml │ │ │ └── ucrop_vector_loader_animated.xml │ │ ├── drawable-xhdpi/ │ │ │ └── transparent.xml │ │ ├── layout/ │ │ │ ├── activity_matisse.xml │ │ │ ├── activity_media_preview.xml │ │ │ ├── dialog_bottom_sheet.xml │ │ │ ├── dialog_bottom_sheet_folder.xml │ │ │ ├── fragment_media_selection.xml │ │ │ ├── fragment_picture_preview_item.xml │ │ │ ├── fragment_preview_item.xml │ │ │ ├── include_view_bottom.xml │ │ │ ├── include_view_navigation.xml │ │ │ ├── item_album_folder.xml │ │ │ ├── item_media_grid.xml │ │ │ ├── item_photo_capture.xml │ │ │ ├── ucrop_activity_photobox.xml │ │ │ ├── ucrop_aspect_ratio.xml │ │ │ ├── ucrop_layout_rotate_wheel.xml │ │ │ ├── ucrop_layout_scale_wheel.xml │ │ │ ├── ucrop_picture_activity_multi_cutting.xml │ │ │ ├── ucrop_picture_gf_adapter_edit_list.xml │ │ │ ├── ucrop_view.xml │ │ │ └── view_media_grid_content.xml │ │ ├── menu/ │ │ │ └── ucrop_menu_activity.xml │ │ ├── values/ │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── colors_default.xml │ │ │ ├── dimens.xml │ │ │ ├── ids.xml │ │ │ ├── long_attrs.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ └── values.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── matisse/ │ └── ExampleUnitTest.java └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/libraries /.idea/modules.xml /.idea/workspace.xml .DS_Store .idea /build build /captures .externalNativeBuild /gradle ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. ================================================ FILE: README.md ================================================ [ ![Download](https://api.bintray.com/packages/nfleo/MatisseKotlin/MatisseKotlin/images/download.svg?version=2.1.1) ](https://bintray.com/nfleo/MatisseKotlin/MatisseKotlin/2.1.1/link) ![Image](/image/banner.png) [Matisse-kotlin地址](https://github.com/NFLeo/Matisse-Kotlin)[包体大小278kb]() 首先感谢: Matisse核心功能:[https://github.com/zhihu/Matisse](https://github.com/zhihu/Matisse) 裁剪提供者:Yalantis github地址:[https://github.com/Yalantis/uCrop](https://github.com/Yalantis/uCrop)  完整说明文档:[Android 图片选择库 MatisseKotlin 版](https://www.jianshu.com/p/ca1e7460fa69) # 版本更新记录 2020-4-2 (v_2.1.1) 1. 修复裁剪结果无法带回问题 2020-4-2 1. 修复部分设备 column '_data' does not exist 2020-3-30 注:升级后,旧裁剪无法使用 1. 裁剪适配Android Q,实现为UCrop 裁剪功能 主要暴露两个开关 isCrop(boolean)、isCircleCrop(boolean) 2. 修复部分华为设备图片无法裁剪问题 3. 修复预览大概率ANR问题 2020-1-18 1. 裁剪适配Android Q 2. 去除内部压缩-后期将使用接入Luban压缩 3. 修改提示方式 setNoticeConsumer { context, noticeType, title, message -> showToast(context, noticeType, title, message) } 4. 修改状态栏处理方法 setStatusBarFuture { params, view -> // 外部设置状态栏 ImmersionBar.with(params)?.run { statusBarDarkFont(true) view?.apply { titleBar(this) } init() } // 外部可隐藏Matisse界面中的标题栏 // view?.visibility = if (isDarkStatus) View.VISIBLE else View.GONE } 5. 注意: 单独调用拍照走裁剪时,也需要创建SelectionCreator 2020-1-17 (v_2.0.5) 1. 修复Gif图预览崩溃问题 2020-1-14 (v_2.0.5) 1. 拍照、展示图片适配Android Q 2020-1-3 (2.0.3) 1. 修复PreviewPageAdapter获取资源数组越界 2019-12-30 1. 默认关闭内部压缩 2. 修复MatisseActivity中albumLoad空指针异常 3. 修复Item创建时,部分cursor为空问题 4. 修复demo压缩开关出错问题 2019-12-23 1. 修复Gif图片类型无法正确判断问题 2. 修复部分设备第一次拍照后,拍照结果不显示问题 2019-12-19 1. 主题属性命名规范化 见R.style.CustomMatisseStyle 2. MimeTypeManager类新增ofMotionlessImage()静态图类型 3. Item列宽(spanSize和gridExceptedSize)添加限制,避免过大/过小 4. 去除app_name 5. 消除选中闪烁 6. 精简压缩库大小 目前release aar 278k 2019-12-10 (2.0.2) 1. 修复mimeType为空情况 2. 修复spanSize和gridExceptedSize同时设置冲突 注:同时设置时,读取gridExceptedSize值 2019-11-10 (2.0.1) 1. 修复裁剪结果尺寸异常 2019-11-4 (2.0) 1. 相机单独提取 2. 支持默认选中,可传入上次选中的项(通过图片cursor id或uri string对比) 注:不支持裁剪带回的图片,裁剪带回的图片无id和uri ``` .setLastChoosePicturesIdOrUri(selectedPathIds as ArrayList?) ``` 3. 修复压缩为空带回崩溃 2019-10-29 (1.2.3) 1. 修复相册弹窗高度不准确问题 2. 支持压缩配置,外部添加开关 api:[isInnerCompress] 3. 完善未选中资源时各按钮点击添加提示 4. 修复不同设备返回的媒体类型表示不一致(如:JPEG image/jpeg) 5. 去除[api setStatusIsDark], 外部处理状态栏,见[api setStatusBarFuture] 2019-10-28 (持续更新 待发布) 1. 支持相机拍照完成后多选 2. 扩展提示方法,支持使用外部自定义弹窗 3. 支持外部处理状态栏,去除项目中原[ImmersionBar]库 ``` .setStatusBarFuture(object : MFunction { override fun accept(params: BaseActivity, view: View?) { // 外部设置状态栏 ImmersionBar.with(params)?.run { statusBarDarkFont(isDarkStatus) view?.apply { titleBar(this) } init() } // 外部可隐藏Matisse界面中的View view?.visibility = if (isDarkStatus) View.VISIBLE else View.GONE } }) ``` 4. 按官方方式适配Android Q 2019-10-21 (1.2.2) 1. 修复方形裁剪图片变形问题 2. 优化单选/多选刷新问题 2019-10-18 (1.2.0) 1. 迁移到androidx 2. 修复并支持图片与视频混合选择 ``` 设置选择单一类型媒体,示例如下 Matisse.choose(MimeTypeManager.ofAll()) .maxSelectable(3) 或者 Matisse.choose(MimeTypeManager.ofAll(), true) .maxSelectable(3) 设置选择混合类型媒体,示例如下 Matisse.choose(MimeTypeManager.ofAll(), false) .maxSelectablePerMediaType(4, 2) 说明: mediaTypeExclusive true 单一媒体类型选择 读取maxSelectable属性作为最大值 mediaTypeExclusive false 读取maxImageSelectable和maxVideoSelectable属性分别作为最大值 ``` 3. 修改单/多选逻辑 * 单选支持重新选定,不支持计数方式 * 多选不支持重新选定,选满外部给出提示方式,支持计数与选中方式 4. 提示方式外部实现 ``` SelectionCreator.setNoticeEvent( { context: Context, noticeType: Int, title: String, message: String -> // 外部提示,可外部定义样式 showToast(context, noticeType, title, message) } }) ``` 2019-10-16 1. 完善主题扩展,并提供图片说明 # Matisse 本项目为知乎原项目kotlin改写版本(2018/9月版本),由于项目纯图片选择库与[Matisse](https://github.com/zhihu/Matisse)UI风格有较大差异,为方便个人使用顺手便对[Matisse](https://github.com/zhihu/Matisse)进行Kotlin翻译,主要对原项目进行部分UI层面改写、已发现bug的修改、新功能添加。 *主要修改内容为:* ``` 1. 优化相册选择。 2. 优化单选策略。 3. 添加圆形与方形裁剪。 4. 图片选择后压缩,不失真条件下高比率压缩。 5. 增加主题修改,基本可保证定制成与自身项目风格一致 6. 支持设置状态栏颜色 需依赖[ImmersionBar](https://github.com/gyf-dev/ImmersionBar) 1.2.2之后版本去除内部ImmersionBar处理 * 注:裁剪成功后只返回裁剪后图片的绝对路径,不返回Uri,需自行转换 具体调用查看 SelectionCreator.java 关于打包报错问题: 使用: 1. gradle中添加 implementation 'com.nfleo:MatisseKotlin:2.0.4' 2. AndroidManifest.xml中添加以下代码 * 注:注意provider androidx的差别 3. 6.0+需处理权限 The library requires two permissions: - `android.permission.READ_EXTERNAL_STORAGE` - `android.permission.WRITE_EXTERNAL_STORAGE` 4. 为适配7.0,,项目manifest的privider标签下 paths文件中添加 文件名称为file_paths_public(名字随意取,但需与AndroidManifest.xml中引用保持一致) | Default Style | Other Style Preview | Preview | |:------------------------------:|:---------------------------------:|:--------------------------------:| |![](image/screenshot_default.jpg) | ![](image/screenshot_other.jpg) | ![](image/screenshot_preview.jpg)| | Circle Crop | Square Crop | |:------------------------------:|:---------------------------------:| |![](image/screenshot_circlecrop.jpg) | ![](image/screenshot_squarecrop.jpg) | #### Simple usage snippet ------ ### 配置主题. | Media theme | Preview theme | |:------------------------------:|:---------------------------------:| |![](image/media_theme_describe.png) | ![](image/preview_theme_describe.png) | 使用套路与原项目一致,只是多增加了一些参数,另外定制时需配置所提供的所有参数。 使用主题时可直接使用Matisse.Default, 或者在自己项目中另写style继承自Matisse.Default,修改自己所需属性,如下 ``` app/style.xml ``` ``` matisse/style.xml 如需定制UI样式 按需修改,否则使用上述默认主题 Matisse.from(this@MainActivity) .setStatusIsDark(true) // 按需设置状态栏文字颜色 .theme(R.style.Matisse_Default) // 设置成所需主题 ``` ### 注意:1.1.1版本支持外部设置设置状态栏颜色,关于状态栏与状态栏底色问题 ##### 项目内部 仅添加了该库(`compileOnly 'com.gyf.barlibrary:barlibrary:2.3.0'`)的编译 需要根据样式修改状态栏文字颜色,图片预览界面状态栏隐藏功能,需在自己项目中引入该库 ``` Matisse.from(this@MainActivity) ... .setStatusIsDark(true) // 设置状态栏文字颜色 true=黑色 false=白色 .theme(R.style.Matisse_Default) // 设置成所需主题 ... ``` Start `MatisseActivity` from current `Activity` or `Fragment`: ``` kotlin项目调用 Matisse.from(MainActivity.this) .choose(MimeTypeManager.ofAll(), false) .countable(true) .maxSelectable(9) .addFilter(new GifSizeFilter(320, 320, 5 * Filter.K * Filter.K)) .gridExpectedSize(getResources().getDimensionPixelSize(R.dimen.grid_expected_size)) .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) .thumbnailScale(0.85f) .imageEngine(new GlideEngine()) .forResult(REQUEST_CODE_CHOOSE); java项目调用 Matisse.Companion.from(MainActivity.this) .choose(MimeTypeManager.Companion.ofAll(), false) .countable(true) .maxSelectable(9) .addFilter(new GifSizeFilter(320, 320, 5 * Filter.K * Filter.K)) .gridExpectedSize(getResources().getDimensionPixelSize(R.dimen.grid_expected_size)) .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) .thumbnailScale(0.85f) .imageEngine(new GlideEngine()) .forResult(REQUEST_CODE_CHOOSE); Matisse.from(SampleActivity.this) .choose(MimeTypeManager.ofAll(), false) // 展示所有类型文件(图片 视频 gif) .capture(true) // 可拍照 .countable(true) // 记录文件选择顺序 .captureStrategy(new CaptureStrategy(true, "cache path")) .maxSelectable(1) // 最多选择一张 .isCrop(true) // 开启裁剪 .isCircleCrop(true) // 设置裁剪类型 .addFilter(new GifSizeFilter(320, 320, 5 * Filter.K * Filter.K)) // 筛选数据源可选大小限制 .gridExpectedSize(getResources().getDimensionPixelSize(R.dimen.grid_expected_size)) .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) .thumbnailScale(0.8f) .setStatusIsDark(true) // 设置状态栏文字颜色 需依赖ImmersionBar库 .imageEngine(new GlideEngine()) // 加载库需外部实现 .forResult(REQUEST_CODE_CHOOSE); ``` 为方便后期兼容Fresco,图片加载类需外部实现 **注意:**目前慎用Fresco(尽管提供了栗子)!!!,图片加载兼容了Fresco,***但,图片放大预览并未兼容*** ``` class Glide4Engine : ImageEngine { override fun cleanMemory(context: Context) { if (Looper.myLooper() == Looper.getMainLooper()) { Glide.get(context).clearMemory() } } override fun pause(context: Context) { Glide.with(context).pauseRequests() } override fun resume(context: Context) { Glide.with(context).resumeRequests() } override fun init(context: Context) { } override fun loadThumbnail(context: Context, resize: Int, placeholder: Drawable, imageView: ImageView, uri: Uri) { Glide.with(context) .asBitmap() // some .jpeg files are actually gif .load(uri) .apply(RequestOptions() .override(resize, resize) .placeholder(placeholder) .centerCrop()) .into(imageView) } override fun loadGifThumbnail(context: Context, resize: Int, placeholder: Drawable, imageView: ImageView, uri: Uri) { Glide.with(context) .asBitmap() // some .jpeg files are actually gif .load(uri) .apply(RequestOptions() .override(resize, resize) .placeholder(placeholder) .centerCrop()) .into(imageView) } override fun loadImage(context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri) { Glide.with(context) .load(uri) .apply(RequestOptions() .override(resizeX, resizeY) .priority(Priority.HIGH) .fitCenter()) .into(imageView) } override fun loadGifImage(context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri) { Glide.with(context) .asGif() .load(uri) .apply(RequestOptions() .override(resizeX, resizeY) .priority(Priority.HIGH) .fitCenter()) .into(imageView) } } ``` ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion rootProject.ext.compileSdkVersion defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion applicationId "com.leo.matisse" versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility 1.8 targetCompatibility 1.8 } } repositories { flatDir { dirs 'libs' } } /** * @Parcelize 注解方式显示序列化 * */ androidExtensions { experimental = true } dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' implementation 'androidx.appcompat:appcompat:1.1.0' implementation "com.github.bumptech.glide:glide:$rootProject.ext.glide" implementation 'com.google.android.material:material:1.0.0' /*动态权限*/ implementation 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.5@aar' implementation 'io.reactivex.rxjava2:rxjava:2.2.12' implementation 'com.facebook.fresco:fresco:1.14.1' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'com.gyf.barlibrary:barlibrary:2.3.0' // implementation project(':matisse') implementation 'com.nfleo:MatisseKotlin:2.1.1' } ================================================ FILE: app/proguard-rules.pro ================================================ # 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 ================================================ FILE: app/src/androidTest/java/com/leo/matisse/ExampleInstrumentedTest.kt ================================================ package com.leo.matisse import android.support.test.InstrumentationRegistry import android.support.test.runner.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getTargetContext() assertEquals("com.leo.matisse", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/leo/matisse/ExampleActivity.kt ================================================ package com.leo.matisse import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo import android.net.Uri import android.os.Bundle import android.view.View import android.widget.CompoundButton import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.bumptech.glide.Glide import com.gyf.barlibrary.ImmersionBar import com.matisse.Matisse import com.matisse.MimeType import com.matisse.MimeTypeManager import com.matisse.SelectionCreator import com.matisse.entity.CaptureStrategy import com.matisse.entity.ConstValue import com.matisse.entity.IncapableCause import com.matisse.ui.activity.BaseActivity import com.matisse.utils.MediaStoreCompat import com.matisse.utils.Platform import com.matisse.utils.gotoImageCrop import com.matisse.widget.IncapableDialog import com.tbruyelle.rxpermissions2.RxPermissions import kotlinx.android.synthetic.main.activity_example.* import java.util.* class ExampleActivity : AppCompatActivity(), View.OnClickListener { private var showType = MimeTypeManager.ofAll() private var showCustomizeType: MutableList? = null private var mediaTypeExclusive = true private var isSingleChoose = false private var isCountable = true private var defaultTheme = R.style.Matisse_Default private var maxCount = 5 private var maxImageCount = 1 private var maxVideoCount = 1 private var isOpenCamera = false private var spanCount = 3 private var gridSizePx = 0 private var isCrop = false private var isCircleCrop = false private var isColumnNum = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_example) initListener() } private fun initListener() { rg_show.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.btnAll -> showType = MimeTypeManager.ofAll() R.id.btnVideo -> showType = MimeTypeManager.ofVideo() R.id.btnImage -> showType = MimeTypeManager.ofImage() } } rg_media_exclusive.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.btnMixed -> { mediaTypeExclusive = false ev_max_1.visibility = View.VISIBLE ev_max_2.visibility = if (isSingleChoose) View.GONE else View.VISIBLE tv_max_1.text = "图片最大选择数" } R.id.btnExclusive -> { mediaTypeExclusive = true ev_max_1.visibility = View.VISIBLE ev_max_2.visibility = View.GONE tv_max_1.text = "最大可选择数" } } } rg_theme.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.btn_normal_theme -> defaultTheme = R.style.Matisse_Default R.id.btn_customize_theme -> defaultTheme = R.style.CustomMatisseStyle R.id.btn_jc_theme -> defaultTheme = R.style.JCStyle } } rg_column.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.btn_num_column -> { isColumnNum = true ev_column.setText("3") } R.id.btn_size_column -> { isColumnNum = false ev_column.setText("300") } } } if (showCustomizeType != null && showCustomizeType?.size ?: 0 > 0) { showType = MimeTypeManager.of(showCustomizeType!![0], showCustomizeType?.toTypedArray()!!) } switch_choose_type.setOnCheckedChangeListener { _, isChecked -> isSingleChoose = isChecked if (isSingleChoose) { maxCount = 1 maxImageCount = 1 maxVideoCount = 1 ev_max_1.visibility = View.GONE ev_max_2.visibility = View.GONE // 单选才支持裁剪 ll_crop.visibility = View.VISIBLE } else { if (mediaTypeExclusive) { tv_max_1.text = "最大可选择数" ev_max_2.visibility = View.GONE ev_max_1.visibility = View.VISIBLE } else { tv_max_1.text = "图片最大选择数" ev_max_1.visibility = View.VISIBLE ev_max_2.visibility = View.VISIBLE } ll_crop.visibility = View.GONE } } switch_check_type.setOnCheckedChangeListener { _, isChecked -> isCountable = !isChecked } switch_capture.setOnCheckedChangeListener { _, isChecked -> isOpenCamera = isChecked } switch_crop.setOnCheckedChangeListener { _, isChecked -> isCrop = isChecked if (isChecked) { ll_crop_type.visibility = View.VISIBLE } else { ll_crop_type.visibility = View.GONE } } switch_crop_type.setOnCheckedChangeListener { _, isChecked -> isCircleCrop = isChecked } chb_jpeg.setOnCheckedChangeListener(checkedOnCheckedListener) chb_png.setOnCheckedChangeListener(checkedOnCheckedListener) chb_gif.setOnCheckedChangeListener(checkedOnCheckedListener) chb_bmp.setOnCheckedChangeListener(checkedOnCheckedListener) chb_webp.setOnCheckedChangeListener(checkedOnCheckedListener) chb_mpeg.setOnCheckedChangeListener(checkedOnCheckedListener) chb_mp4.setOnCheckedChangeListener(checkedOnCheckedListener) chb_quick_time.setOnCheckedChangeListener(checkedOnCheckedListener) chb_threegpp.setOnCheckedChangeListener(checkedOnCheckedListener) chb_threegpp2.setOnCheckedChangeListener(checkedOnCheckedListener) chb_mkv.setOnCheckedChangeListener(checkedOnCheckedListener) chb_webm.setOnCheckedChangeListener(checkedOnCheckedListener) chb_ts.setOnCheckedChangeListener(checkedOnCheckedListener) chb_avi.setOnCheckedChangeListener(checkedOnCheckedListener) btn_open_matisse.setOnClickListener(this) btn_open_capture.setOnClickListener(this) } @SuppressLint("CheckResult") override fun onClick(v: View?) { RxPermissions(this@ExampleActivity) .request( Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE ) .subscribe { if (!it) { showToast( this, IncapableCause.TOAST, "", getString(R.string.permission_request_denied) ) return@subscribe } createMatisse() when (v) { btn_open_matisse -> { openMatisse() } btn_open_capture -> { createMediaStoreCompat() mediaStoreCompat?.dispatchCaptureIntent( this, ConstValue.REQUEST_CODE_CAPTURE ) } } } } private fun showToast(context: Context, noticeType: Int, title: String, message: String) { if (noticeType == IncapableCause.TOAST) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } else if (noticeType == IncapableCause.DIALOG) { // 外部弹窗,可外部定义样式 val incapableDialog = IncapableDialog.newInstance(title, message) incapableDialog.show( (context as BaseActivity).supportFragmentManager, IncapableDialog::class.java.name ) } else if(noticeType == IncapableCause.LOADING) { } } private var checkedOnCheckedListener = CompoundButton.OnCheckedChangeListener { buttonView, isChecked -> val mimeType = when (buttonView) { chb_jpeg -> MimeType.JPEG chb_png -> MimeType.PNG chb_gif -> MimeType.GIF chb_bmp -> MimeType.BMP chb_webp -> MimeType.WEBP chb_mpeg -> MimeType.MPEG chb_mp4 -> MimeType.MP4 chb_quick_time -> MimeType.QUICKTIME chb_threegpp -> MimeType.THREEGPP chb_threegpp2 -> MimeType.THREEGPP2 chb_mkv -> MimeType.MKV chb_webm -> MimeType.WEBM chb_ts -> MimeType.TS chb_avi -> MimeType.AVI else -> null } ?: return@OnCheckedChangeListener if (showCustomizeType == null) showCustomizeType = mutableListOf() if (isChecked) { showCustomizeType?.add(mimeType) } else { showCustomizeType?.remove(mimeType) } } private var mediaStoreCompat: MediaStoreCompat? = null private var selectionCreator: SelectionCreator? = null private var selectedPathIds: List? = null private fun createMatisse() { setEditText() selectionCreator = Matisse.from(this@ExampleActivity) // 绑定Activity/Fragment .choose( showType, mediaTypeExclusive ) // 设置显示类型,单一/混合选择模式 .theme(defaultTheme) // 外部设置主题样式 .countable(isCountable) // 设置选中计数方式 .isCrop(isCrop) // 设置开启裁剪 .isCircleCrop(isCircleCrop) // 裁剪类型,圆形/方形 .maxSelectable(maxCount) // 单一选择下 最大选择数量 .maxSelectablePerMediaType( maxImageCount, maxVideoCount ) // 混合选择下 视频/图片最大选择数量 .capture(isOpenCamera) // 是否开启内部拍摄 .captureStrategy( // 拍照设置Strategy CaptureStrategy( true, "${Platform.getPackageName(this@ExampleActivity)}.fileprovider" ) ) .thumbnailScale(0.6f) // 图片显示压缩比 .spanCount(spanCount) // 资源显示列数 .gridExpectedSize(gridSizePx) // 资源显示网格列宽度 .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) // 强制屏幕方向 .imageEngine(Glide4Engine()) // 图片加载实现方式 .setLastChoosePicturesIdOrUri(selectedPathIds as ArrayList?)// 预选中 .setNoticeConsumer { context, noticeType, title, message -> showToast(context, noticeType, title, message) }.setStatusBarFuture { params, view -> // 外部设置状态栏 ImmersionBar.with(params)?.run { statusBarDarkFont(true) view?.apply { titleBar(this) } init() } // 外部可隐藏Matisse界面中的标题栏 // view?.visibility = if (isDarkStatus) View.VISIBLE else View.GONE } } private fun createMediaStoreCompat() { if (mediaStoreCompat != null) return val captureStrategy = CaptureStrategy( true, "${Platform.getPackageName(this@ExampleActivity)}.fileprovider" ) mediaStoreCompat = MediaStoreCompat(this, null) mediaStoreCompat?.setCaptureStrategy(captureStrategy) } private fun openMatisse() { selectionCreator?.forResult(ConstValue.REQUEST_CODE_CHOOSE) } private fun setEditText() { if (!mediaTypeExclusive) { maxImageCount = formatStrTo0(ev_max_1.text.toString()) maxVideoCount = formatStrTo0(ev_max_2.text.toString()) } else { if (!isSingleChoose) { maxCount = formatStrTo0(ev_max_1.text.toString()) } } if (isColumnNum) { spanCount = formatStrTo0(ev_column.text.toString()) gridSizePx = 0 } else { gridSizePx = formatStrTo0(ev_column.text.toString()) spanCount = 0 } } private fun formatStrTo0(s: String?): Int { if (s == null || s.toString() == "") { Toast.makeText(this, "请保证所有输入非0非空,否则崩溃", Toast.LENGTH_SHORT).show() return 0 } return s.toString().toInt() } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode != Activity.RESULT_OK) return when (requestCode) { ConstValue.REQUEST_CODE_CHOOSE -> doActivityResultForChoose(data) ConstValue.REQUEST_CODE_CAPTURE -> doActivityResultForCapture() ConstValue.REQUEST_CODE_CROP -> doActivityResultForCrop(data) } } private fun doActivityResultForChoose(data: Intent?) { if (data == null) return // 获取uri返回值 裁剪结果不返回uri val uriList = Matisse.obtainResult(data) // 获取文件路径返回值 selectedPathIds = Matisse.obtainPathIdResult(data) uriList?.apply { Glide.with(this@ExampleActivity).load(this[0]).into(iv_image) } showPictureResult(uriList, uriList) } private fun doActivityResultForCapture() { mediaStoreCompat?.getCurrentPhotoUri()?.apply { if (isCrop) { gotoImageCrop(this@ExampleActivity, arrayListOf(this)) } else { showCompressedPath(this) } } } private fun doActivityResultForCrop(data: Intent?) { data?.run { Matisse.obtainCropResult(data)?.let { showCompressedPath(it) } } } private fun showCompressedPath(path: Uri) { showPictureResult(null, arrayListOf(path)) Glide.with(this).load(path).into(iv_image) } private fun showPictureResult( uriList: List?, strList: List? ) { var string = "uri 路径集合:\n" uriList?.forEach { string += it.toString() + "\n" } string += "\npath 路径集合:\n" strList?.forEach { string += it.toString() + "\n" } text.text = "\n\n$string" } } ================================================ FILE: app/src/main/java/com/leo/matisse/FrescoEngine.kt ================================================ package com.leo.matisse import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri import androidx.core.view.ViewCompat import android.view.ViewGroup import android.widget.ImageView import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.drawee.generic.GenericDraweeHierarchy import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder import com.facebook.drawee.interfaces.DraweeController import com.facebook.drawee.view.DraweeHolder import com.facebook.imagepipeline.common.ResizeOptions import com.facebook.imagepipeline.request.ImageRequestBuilder import com.matisse.engine.ImageEngine /** * Describe : * Created by Leo on 2018/10/9 on 15:07. */ class FrescoEngine : ImageEngine { override fun loadThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) { var hierarchy: GenericDraweeHierarchy? = null val hierarchyBuilder = GenericDraweeHierarchyBuilder.newInstance(imageView.context.resources) var draweeHolder: DraweeHolder<*>? = imageView.getTag(R.id.fresco_drawee) as DraweeHolder<*>? hierarchyBuilder.placeholderImage = placeholder hierarchyBuilder.failureImage = placeholder if (hierarchy == null) { hierarchy = hierarchyBuilder.build() } val controllerBuilder = Fresco.newDraweeControllerBuilder().setUri(uri).setAutoPlayAnimations(true) val imageRequestBuilder = ImageRequestBuilder.newBuilderWithSource(uri) imageRequestBuilder.resizeOptions = ResizeOptions(resize, resize) val request = imageRequestBuilder.build() controllerBuilder.imageRequest = request val controller: DraweeController if (draweeHolder == null) { draweeHolder = DraweeHolder.create(hierarchy, context) controller = controllerBuilder.build() } else { controller = controllerBuilder.setOldController(draweeHolder.controller).build() } draweeHolder?.controller = controller if (ViewCompat.isAttachedToWindow(imageView)) { draweeHolder?.onAttach() } imageView.setTag(R.id.fresco_drawee, draweeHolder) imageView.setImageDrawable(draweeHolder?.topLevelDrawable) } override fun loadGifThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) { } override fun loadImage( context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri? ) { var hierarchy: GenericDraweeHierarchy? = null val hierarchyBuilder = GenericDraweeHierarchyBuilder.newInstance(imageView.context.resources) var draweeHolder: DraweeHolder<*>? = imageView.getTag(R.id.fresco_drawee) as DraweeHolder<*>? if (hierarchy == null) { hierarchy = hierarchyBuilder.build() } val controllerBuilder = Fresco.newDraweeControllerBuilder().setUri(uri).setAutoPlayAnimations(true) var params: ViewGroup.LayoutParams? = imageView.layoutParams if (params == null) { params = ViewGroup.LayoutParams(resizeX, resizeY) } if (params.width == ViewGroup.LayoutParams.WRAP_CONTENT) { params.width = ViewGroup.LayoutParams.MATCH_PARENT } if (params.height == ViewGroup.LayoutParams.WRAP_CONTENT) { params.height = ViewGroup.LayoutParams.MATCH_PARENT } imageView.layoutParams = params val imageRequestBuilder = ImageRequestBuilder.newBuilderWithSource(uri) val request = imageRequestBuilder.build() controllerBuilder.imageRequest = request val controller: DraweeController if (draweeHolder == null) { draweeHolder = DraweeHolder.create(hierarchy, context) controller = controllerBuilder.build() } else { controller = controllerBuilder.setOldController(draweeHolder.controller).build() } // 请求 draweeHolder?.controller = controller if (ViewCompat.isAttachedToWindow(imageView)) { draweeHolder?.onAttach() } imageView.setTag(R.id.fresco_drawee, draweeHolder) imageView.setImageDrawable(draweeHolder?.topLevelDrawable) } override fun loadGifImage( context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri? ) { } override fun cleanMemory(context: Context) { Fresco.getImagePipeline().clearMemoryCaches() } override fun pause(context: Context) { Fresco.getImagePipeline().pause() } override fun resume(context: Context) { Fresco.getImagePipeline().resume() } override fun init(context: Context) { Fresco.initialize(context) } } ================================================ FILE: app/src/main/java/com/leo/matisse/Glide4Engine.kt ================================================ package com.leo.matisse import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri import android.os.Looper import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.Priority import com.bumptech.glide.request.RequestOptions import com.matisse.engine.ImageEngine /** * [ImageEngine] implementation using Glide. */ class Glide4Engine : ImageEngine { override fun cleanMemory(context: Context) { if (Looper.myLooper() == Looper.getMainLooper()) { Glide.get(context).clearMemory() } } override fun pause(context: Context) { Glide.with(context).pauseRequests() } override fun resume(context: Context) { Glide.with(context).resumeRequests() } override fun init(context: Context) { } override fun loadThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) { Glide.with(context) .asBitmap() // some .jpeg files are actually gif .load(uri) .apply( RequestOptions() .override(resize, resize).dontAnimate() .placeholder(placeholder).centerCrop() ) .into(imageView) } override fun loadGifThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) { Glide.with(context) .asBitmap() // some .jpeg files are actually gif .load(uri) .apply( RequestOptions().override(resize, resize).placeholder(placeholder).centerCrop() ) .into(imageView) } override fun loadImage( context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri? ) { Glide.with(context) .load(uri) .apply( RequestOptions() .override(resizeX, resizeY).priority(Priority.HIGH).fitCenter() ) .into(imageView) } override fun loadGifImage( context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri? ) { Glide.with(context) .asGif() .load(uri) .apply( RequestOptions().override(resizeX, resizeY).priority(Priority.HIGH).fitCenter() ) .into(imageView) } } ================================================ FILE: app/src/main/java/com/leo/matisse/GlideEngine.kt ================================================ package com.leo.matisse import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri import android.os.Looper import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.Priority import com.bumptech.glide.request.RequestOptions import com.matisse.engine.ImageEngine /** * Describe : implementation using Glide. * Created by Leo on 2018/9/7 on 10:55. */ class GlideEngine : ImageEngine { override fun cleanMemory(context: Context) { if (Looper.myLooper() == Looper.getMainLooper()) { Glide.get(context).clearMemory() } } override fun pause(context: Context) { Glide.with(context).pauseRequests() } override fun resume(context: Context) { Glide.with(context).resumeRequests() } override fun init(context: Context) { } override fun loadThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) { Glide.with(context) .asBitmap() // some .jpeg files are actually gif .load(uri) .apply( RequestOptions().placeholder(placeholder) .override(resize, resize) .centerCrop() ) .into(imageView) } override fun loadGifThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) { Glide.with(context) .asBitmap() .load(uri) .apply( RequestOptions().placeholder(placeholder) .override(resize, resize) .centerCrop() ) .into(imageView) } override fun loadImage( context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri? ) { Glide.with(context) .load(uri) .apply( RequestOptions().priority(Priority.HIGH) .fitCenter() ) .into(imageView) } override fun loadGifImage( context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri? ) { Glide.with(context) .asGif() .load(uri) .apply( RequestOptions().priority(Priority.HIGH) .override(resizeX, resizeY) ) .into(imageView) } } ================================================ FILE: app/src/main/java/com/leo/matisse/ImageSizeFilter.kt ================================================ package com.leo.matisse import android.content.Context import com.matisse.MimeTypeManager import com.matisse.entity.IncapableCause import com.matisse.entity.Item import com.matisse.filter.Filter import com.matisse.utils.PhotoMetadataUtils /** * desc:不允许选择大于itemSize字节的图片
* time: 2019/10/23-10:12
* author:Leo
* since V 1.2.2
*/ class ImageSizeFilter(private var itemSize: Long) : Filter() { // 设置需过滤的资源类型,此处过滤类型为图片 override fun constraintTypes() = MimeTypeManager.ofImage() override fun filter(context: Context, item: Item?): IncapableCause? { // 1. 判断当前选中的item是否属于constraintTypes中定义的图片资源 if (!needFiltering(context, item)) return null if (item?.size ?: 0 > itemSize) { return IncapableCause( IncapableCause.DIALOG, "需选择小于${PhotoMetadataUtils.getSizeInMB(itemSize)}M的图片" ) } return null } } ================================================ FILE: app/src/main/java/com/leo/matisse/MainActivity.kt ================================================ package com.leo.matisse import android.Manifest import android.app.Activity import android.content.Intent import android.content.pm.ActivityInfo import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatButton import com.bumptech.glide.Glide import com.matisse.Matisse import com.matisse.MimeTypeManager import com.matisse.entity.CaptureStrategy import com.matisse.entity.ConstValue import com.matisse.utils.Platform import com.tbruyelle.rxpermissions2.RxPermissions import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById(R.id.btn_media_store).setOnClickListener { RxPermissions(this@MainActivity) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA) .subscribe { if (!it) { Toast.makeText( this@MainActivity, R.string.permission_request_denied, Toast.LENGTH_LONG ).show() return@subscribe } openMatisse() } } } private fun openMatisse() { Matisse.from(this@MainActivity) .choose(MimeTypeManager.ofAll()) .countable(false) .capture(true) .isCrop(true) .isCircleCrop(true) .maxSelectable(1) .theme(R.style.JCStyle) .captureStrategy( CaptureStrategy( true, "${Platform.getPackageName(this@MainActivity)}.fileprovider" ) ) .thumbnailScale(0.8f) .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) .imageEngine(Glide4Engine()) .forResult(ConstValue.REQUEST_CODE_CHOOSE) } private fun showToast(value: String) { Toast.makeText(this, value, Toast.LENGTH_SHORT).show() } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (data == null) return if (requestCode == ConstValue.REQUEST_CODE_CHOOSE && resultCode == Activity.RESULT_OK) { var string = "" val uriList = Matisse.obtainResult(data) ?: return uriList.forEach { string += it.toString() + "\n" } Glide.with(this).load(uriList[0]).into(iv_image) string = "\n\n$string" text.text = "\n\n$string" } } } ================================================ FILE: app/src/main/java/com/leo/matisse/SizeFilter.kt ================================================ package com.leo.matisse import android.content.Context import com.matisse.MimeType import com.matisse.MimeTypeManager import com.matisse.entity.IncapableCause import com.matisse.entity.Item import com.matisse.filter.Filter import com.matisse.utils.PhotoMetadataUtils class SizeFilter(private val maxSizeByte: Int) : Filter() { override fun constraintTypes(): Set { return MimeTypeManager.ofMotionlessImage() } override fun filter(context: Context, item: Item?): IncapableCause? { if (!needFiltering(context, item)) return null return if (item?.size ?: 0 > maxSizeByte) { IncapableCause( IncapableCause.TOAST, "not larger than ${PhotoMetadataUtils.getSizeInMB(maxSizeByte.toLong())} MB" ) } else null } } ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable-xhdpi/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_example.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #3F51B5 #303F9F #FF4081 #61FFFFFF ================================================ FILE: app/src/main/res/values/colors_dracula.xml ================================================ ================================================ FILE: app/src/main/res/values/ids.xml ================================================ ================================================ FILE: app/src/main/res/values/strings.xml ================================================ MatisseKotlin 请求读写权限失败! Media.Back Sure Media.Preview Media.Original Media.Camera Media.Album ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/main/res/xml/file_paths_public.xml ================================================ ================================================ FILE: app/src/test/java/com/leo/matisse/ExampleUnitTest.kt ================================================ package com.leo.matisse import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = '1.3.50' repositories { google() jcenter() mavenCentral() } dependencies { classpath "com.android.tools.build:gradle:3.4.1" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.novoda:bintray-release:0.9.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { google() jcenter() mavenCentral() maven { url "https://jitpack.io" } } } ext { compileSdkVersion = 29 minSdkVersion = 19 targetSdkVersion = 29 appcompat = '1.0.0' material = '1.0.0' recyclerview = '1.0.0' glide = '4.7.1' constraintlayout = '1.1.3' } tasks.withType(Javadoc) { options.addStringOption('Xdoclint:none', '-quiet') options.addStringOption('encoding', 'UTF-8') } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. android.enableJetifier=true android.useAndroidX=true org.gradle.jvmargs=-Xmx1536m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS="" # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin, switch paths to Windows format before running java if $cygwin ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=$((i+1)) done case $i in (0) set -- ;; (1) set -- "$args0" ;; (2) set -- "$args0" "$args1" ;; (3) set -- "$args0" "$args1" "$args2" ;; (4) set -- "$args0" "$args1" "$args2" "$args3" ;; (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=$(save "$@") # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then cd "$(dirname "$0")" fi exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS= @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto init echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto init echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :init @rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args :win9xME_args @rem Slurp the command line arguments. set CMD_LINE_ARGS= set _SKIP=2 :win9xME_args_slurp if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: matisse/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'com.novoda.bintray-release' Properties properties = new Properties() properties.load(project.rootProject.file('local.properties').newDataInputStream()) String localBintrayUser = properties.getProperty("bintray.user") String localBintrayApikey = properties.getProperty("bintray.apikey") publish { bintrayUser = localBintrayUser //bintray.com用户名 bintrayKey = localBintrayApikey //bintray.com apikey dryRun = false repoName = 'MatisseKotlin' userOrg = 'nfleo'//bintray.com用户名 groupId = 'com.nfleo'//jcenter上的路径 artifactId = 'MatisseKotlin'//项目名称 publishVersion = '2.1.1'//版本号 desc = 'this is for MatisseKotlin' website = 'https://github.com/NFLeo/Matisse-Kotlin' } android { compileSdkVersion rootProject.ext.compileSdkVersion defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } lintOptions { abortOnError false } compileOptions { /* Java8支持 */ sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8 } } androidExtensions { experimental = true } dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" /*ImageViewTouch*/ implementation "it.sephiroth.android.library.imagezoom:library:1.0.4" implementation "androidx.appcompat:appcompat:$rootProject.ext.appcompat" implementation "com.google.android.material:material:$rootProject.ext.material" implementation "androidx.recyclerview:recyclerview:$rootProject.ext.recyclerview" implementation "androidx.constraintlayout:constraintlayout:$rootProject.ext.constraintlayout" } tasks.withType(JavaCompile) { options.encoding = "UTF-8" } ================================================ FILE: matisse/proguard-rules.pro ================================================ # 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 ================================================ FILE: matisse/src/androidTest/java/com/matisse/ExampleInstrumentedTest.java ================================================ package com.matisse; import android.content.Context; import android.support.test.InstrumentationRegistry; import android.support.test.runner.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.assertEquals; /** * Instrumented test, which will execute on an Android device. * * @see Testing documentation */ @RunWith(AndroidJUnit4.class) public class ExampleInstrumentedTest { @Test public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getTargetContext(); assertEquals("com.matisse.test", appContext.getPackageName()); } } ================================================ FILE: matisse/src/main/AndroidManifest.xml ================================================ ================================================ FILE: matisse/src/main/java/com/matisse/Matisse.kt ================================================ package com.matisse import android.app.Activity import android.content.Intent import android.net.Uri import androidx.fragment.app.Fragment import com.matisse.entity.ConstValue import java.lang.ref.WeakReference /** * Entry for Matisse's media selection. */ class Matisse(activity: Activity?, fragment: Fragment? = null) { companion object { /** * Start Matisse from an Activity. * This Activity's [Activity.onActivityResult] will be called when user * finishes selecting. * * @param activity Activity instance. * @return Matisse instance. */ fun from(activity: Activity?): Matisse { return Matisse(activity) } /** * Start Matisse from a Fragment. * * This Fragment's [Fragment.onActivityResult] will be called when user * finishes selecting. * * @param fragment Fragment instance. * @return Matisse instance. */ fun from(fragment: Fragment): Matisse { return Matisse(fragment) } /** * Obtain user selected media' [Uri] list in the starting Activity or Fragment. * * @param data Intent passed by [Activity.onActivityResult] or * [Fragment.onActivityResult]. * @return User selected media' [Uri] list. */ fun obtainResult(data: Intent): List? { return data.getParcelableArrayListExtra(ConstValue.EXTRA_RESULT_SELECTION) } /** * Obtain user selected media path id list in the starting Activity or Fragment. * * @param data Intent passed by [Activity.onActivityResult] or * [Fragment.onActivityResult]. * @return User selected media path id list. */ fun obtainPathIdResult(data: Intent): List? { return data.getStringArrayListExtra(ConstValue.EXTRA_RESULT_SELECTION_ID) } /** * 直接获取裁剪结果 */ fun obtainCropResult(data: Intent?): Uri? { return data?.getParcelableExtra(ConstValue.EXTRA_RESULT_CROP_BACK_BUNDLE) } /** * Obtain state whether user decide to use selected media in original * * @param data Intent passed by [Activity.onActivityResult] or * [Fragment.onActivityResult]. * @return Whether use original photo */ fun obtainOriginalState(data: Intent) = data.getBooleanExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, false) } private val mContext = WeakReference(activity) private val mFragment: WeakReference? internal val activity: Activity? get() = mContext.get() internal val fragment: Fragment? get() = mFragment?.get() private constructor(fragment: Fragment) : this(fragment.activity, fragment) init { mFragment = WeakReference(fragment) } /** * MIME types the selection constrains on. * Types not included in the set will still be shown in the grid but can't be chosen. * * @param mimeTypes MIME types set user can choose from. * @return [SelectionCreator] to build select specifications. * @see MimeType * * @see SelectionCreator */ fun choose(mimeTypes: Set): SelectionCreator { return this.choose(mimeTypes, true) } /** * MIME types the selection constrains on. * Types not included in the set will still be shown in the grid but can't be chosen. * * @param mimeTypes MIME types set user can choose from. * @param mediaTypeExclusive Whether can choose images and videos at the same time during one single choosing * process. true corresponds to not being able to choose images and videos at the same * time, and false corresponds to being able to do this. * @return [SelectionCreator] to build select specifications. * @see MimeType * * @see SelectionCreator */ fun choose(mimeTypes: Set, mediaTypeExclusive: Boolean) = SelectionCreator(this, mimeTypes, mediaTypeExclusive) } ================================================ FILE: matisse/src/main/java/com/matisse/MimeType.kt ================================================ package com.matisse /** * Describe : MIME Type enumeration to restrict selectable media on the selection activity. * Matisse only supports images and videos. * Created by Leo on 2018/8/29 on 14:55. */ enum class MimeType { // ============== images ============== JPG { override fun getValue() = MimeTypeManager.arraySetOf("jpg", "jpeg") override fun getKey() = "image/jpg" }, JPEG { override fun getValue() = MimeTypeManager.arraySetOf("jpg", "jpeg") override fun getKey() = "image/jpeg" }, PNG { override fun getValue() = MimeTypeManager.arraySetOf("png") override fun getKey() = "image/png" }, GIF { override fun getValue() = MimeTypeManager.arraySetOf("gif") override fun getKey() = "image/gif" }, BMP { override fun getValue() = MimeTypeManager.arraySetOf("bmp") override fun getKey() = "image/x-ms-bmp" }, WEBP { override fun getValue() = MimeTypeManager.arraySetOf("webp") override fun getKey() = "image/webp" }, // ============== videos ============== MPEG { override fun getValue() = MimeTypeManager.arraySetOf("mpg") override fun getKey() = "video/mpeg" }, MP4 { override fun getValue() = MimeTypeManager.arraySetOf("m4v", "mp4") override fun getKey() = "video/mp4" }, QUICKTIME { override fun getValue() = MimeTypeManager.arraySetOf("mov") override fun getKey() = "video/quicktime" }, THREEGPP { override fun getValue() = MimeTypeManager.arraySetOf("3gp", "3gpp") override fun getKey() = "video/3gpp" }, THREEGPP2 { override fun getValue() = MimeTypeManager.arraySetOf("3g2", "3gpp2") override fun getKey() = "video/3gpp2" }, MKV { override fun getValue() = MimeTypeManager.arraySetOf("mkv") override fun getKey() = "video/x-matroska" }, WEBM { override fun getValue() = MimeTypeManager.arraySetOf("webm") override fun getKey() = "video/webm" }, TS { override fun getValue() = MimeTypeManager.arraySetOf("ts") override fun getKey() = "video/mp2ts" }, AVI { override fun getValue() = MimeTypeManager.arraySetOf("avi") override fun getKey() = "video/avi" }; abstract fun getValue(): Set abstract fun getKey(): String } ================================================ FILE: matisse/src/main/java/com/matisse/MimeTypeManager.kt ================================================ package com.matisse import android.content.Context import android.net.Uri import android.text.TextUtils import android.webkit.MimeTypeMap import androidx.collection.ArraySet import com.matisse.utils.PhotoMetadataUtils import com.matisse.utils.getRealFilePath import java.util.* /** * Describe : Define MediaType * Created by Leo on 2018/8/29 on 15:02. */ class MimeTypeManager { companion object { fun ofAll(): EnumSet = EnumSet.allOf(MimeType::class.java) fun of(first: MimeType, others: Array): EnumSet = EnumSet.of(first, *others) fun ofImage(): EnumSet = EnumSet.of( MimeType.JPEG, MimeType.JPG, MimeType.PNG, MimeType.GIF, MimeType.BMP, MimeType.WEBP ) // 静态图 fun ofMotionlessImage(): EnumSet = EnumSet.of( MimeType.JPEG, MimeType.JPG, MimeType.PNG, MimeType.BMP ) fun ofVideo(): EnumSet = EnumSet.of( MimeType.MPEG, MimeType.MP4, MimeType.QUICKTIME, MimeType.THREEGPP, MimeType.THREEGPP2, MimeType.MKV, MimeType.WEBM, MimeType.TS, MimeType.AVI ) fun isImage(mimeType: String?) = isMotionlessImage(mimeType) || MimeType.GIF.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.WEBP.getKey().contains(lowerCaseMimeType(mimeType)) private fun isMotionlessImage(mimeType: String?) = MimeType.JPEG.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.JPG.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.PNG.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.BMP.getKey().contains(lowerCaseMimeType(mimeType)) fun isVideo(mimeType: String) = MimeType.MPEG.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.MP4.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.QUICKTIME.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.THREEGPP.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.THREEGPP2.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.MKV.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.WEBM.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.TS.getKey().contains(lowerCaseMimeType(mimeType)) || MimeType.AVI.getKey().contains(lowerCaseMimeType(mimeType)) fun isGif(mimeType: String) = MimeType.GIF.getKey().contains(lowerCaseMimeType(mimeType)) fun arraySetOf(vararg suffixes: String) = ArraySet(mutableListOf(*suffixes)) fun checkType(context: Context, uri: Uri?, mExtensions: Set): Boolean { val map = MimeTypeMap.getSingleton() if (uri == null) return false val type = map.getExtensionFromMimeType(context.contentResolver.getType(uri)) var path: String? = null // lazy load the path and prevent resolve for multiple times var pathParsed = false mExtensions.forEach { if (it == type) return true if (!pathParsed) { // we only resolve the path for one time path = getRealFilePath(context, uri) if (!TextUtils.isEmpty(path)) path = path?.toLowerCase(Locale.US) pathParsed = true } if (path != null && path?.endsWith(it) == true) return true } return false } private fun lowerCaseMimeType(mimeType: String?) = mimeType?.toLowerCase() ?: "" } } ================================================ FILE: matisse/src/main/java/com/matisse/SelectionCreator.kt ================================================ package com.matisse import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo.* import android.os.Build import android.view.View import androidx.annotation.IntDef import androidx.annotation.RequiresApi import androidx.annotation.StyleRes import com.matisse.engine.ImageEngine import com.matisse.entity.CaptureStrategy import com.matisse.filter.Filter import com.matisse.internal.entity.SelectionSpec import com.matisse.listener.OnCheckedListener import com.matisse.listener.OnSelectedListener import com.matisse.ui.activity.BaseActivity import com.matisse.ui.activity.matisse.MatisseActivity import java.io.File /** * Fluent API for building media select specification. * Constructs a new specification builder on the context. * * @param matisse a requester context wrapper. * @param mimeTypes MIME type set to select. */ class SelectionCreator( private val matisse: Matisse, mimeTypes: Set, mediaTypeExclusive: Boolean ) { private val selectionSpec: SelectionSpec = SelectionSpec.getCleanInstance() @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) @IntDef( SCREEN_ORIENTATION_UNSPECIFIED, SCREEN_ORIENTATION_LANDSCAPE, SCREEN_ORIENTATION_PORTRAIT, SCREEN_ORIENTATION_USER, SCREEN_ORIENTATION_BEHIND, SCREEN_ORIENTATION_SENSOR, SCREEN_ORIENTATION_NOSENSOR, SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SCREEN_ORIENTATION_SENSOR_PORTRAIT, SCREEN_ORIENTATION_REVERSE_LANDSCAPE, SCREEN_ORIENTATION_REVERSE_PORTRAIT, SCREEN_ORIENTATION_FULL_SENSOR, SCREEN_ORIENTATION_USER_LANDSCAPE, SCREEN_ORIENTATION_USER_PORTRAIT, SCREEN_ORIENTATION_FULL_USER, SCREEN_ORIENTATION_LOCKED ) @kotlin.annotation.Retention(AnnotationRetention.SOURCE) internal annotation class ScreenOrientation init { selectionSpec.run { this.mimeTypeSet = mimeTypes this.mediaTypeExclusive = mediaTypeExclusive this.orientation = SCREEN_ORIENTATION_UNSPECIFIED } } /** * Theme for media selecting Activity. * * There are two built-in themes: * you can define a custom theme derived from the above ones or other themes. * * @param themeId theme resource id. Default value is R.style.Matisse_Zhihu. * @return [SelectionCreator] for fluent API. */ fun theme(@StyleRes themeId: Int) = this.apply { selectionSpec.themeId = themeId } /** * Show a auto-increased number or a check mark when user select media. * * @param countable true for a auto-increased number from 1, false for a check mark. Default * value is false. * @return [SelectionCreator] for fluent API. */ fun countable(countable: Boolean) = this.apply { selectionSpec.countable = countable } /** * Maximum selectable count. * mediaTypeExclusive true * use maxSelectable * mediaTypeExclusive false * use maxImageSelectable and maxVideoSelectable * @param maxSelectable Maximum selectable count. Default value is 1. * @return [SelectionCreator] for fluent API. */ fun maxSelectable(maxSelectable: Int) = this.apply { if (!selectionSpec.mediaTypeExclusive) return this require(maxSelectable >= 1) { "maxSelectable must be greater than or equal to one" } check(!(selectionSpec.maxImageSelectable > 0 || selectionSpec.maxVideoSelectable > 0)) { "already set maxImageSelectable and maxVideoSelectable" } selectionSpec.maxSelectable = maxSelectable } /** * Only useful when [SelectionSpec.mediaTypeExclusive] set true and you want to set different maximum * selectable files for image and video media types. * * @param maxImageSelectable Maximum selectable count for image. * @param maxVideoSelectable Maximum selectable count for video. * @return */ fun maxSelectablePerMediaType(maxImageSelectable: Int, maxVideoSelectable: Int) = this.apply { if (selectionSpec.mediaTypeExclusive) return this require(!(maxImageSelectable < 1 || maxVideoSelectable < 1)) { "mediaTypeExclusive must be false and max selectable must be greater than or equal to one" } selectionSpec.maxSelectable = -1 selectionSpec.maxImageSelectable = maxImageSelectable selectionSpec.maxVideoSelectable = maxVideoSelectable } /** * Add filter to filter each selecting item. * * @param filter [Filter] * @return [SelectionCreator] for fluent API. */ fun addFilter(filter: Filter) = apply { if (selectionSpec.filters == null) selectionSpec.filters = mutableListOf() selectionSpec.filters?.add(filter) } /** * Determines whether the photo capturing is enabled or not on the media grid view. * If this value is set true, photo capturing entry will appear only on All Media's page. * * @param enable Whether to enable capturing or not. Default value is false; * @return [SelectionCreator] for fluent API. */ fun capture(enable: Boolean) = this.apply { selectionSpec.capture = enable } /** * Show a original photo check options.Let users decide whether use original photo after select * * @param enable Whether to enable original photo or not * @return [SelectionCreator] for fluent API. */ fun originalEnable(enable: Boolean) = this.apply { selectionSpec.originalable = enable } /** * Maximum original size,the unit is MB. Only useful when {link@originalEnable} set true * * @param size Maximum original size. Default value is Integer.MAX_VALUE * @return [SelectionCreator] for fluent API. */ fun maxOriginalSize(size: Int) = this.apply { selectionSpec.originalMaxSize = size } /** * Capture strategy provided for the location to save photos including internal and external * storage and also a authority for [androidx.core.content.FileProvider]. * * @param captureStrategy [CaptureStrategy], needed only when capturing is enabled. * @return [SelectionCreator] for fluent API. */ fun captureStrategy(captureStrategy: CaptureStrategy) = this.apply { selectionSpec.captureStrategy = captureStrategy } /** * Set the desired orientation of this activity. * * @param orientation An orientation constant as used in [ScreenOrientation]. * Default value is [android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT]. * @return [SelectionCreator] for fluent API. * @see Activity.setRequestedOrientation */ fun restrictOrientation(@ScreenOrientation orientation: Int) = this.apply { selectionSpec.orientation = orientation } /** * Set a fixed span count for the media grid. Same for different screen orientations. * This will be ignored when [.gridExpectedSize] is set. * [get gridExpectedSize first] * @param spanCount Requested span count. * @return [SelectionCreator] for fluent API. */ fun spanCount(spanCount: Int) = this.apply { if (selectionSpec.gridExpectedSize > 0) return this selectionSpec.spanCount = spanCount } /** * Set expected size for media grid to adapt to different screen sizes. This won't necessarily * be applied cause the media grid should fill the view container. The measured media grid's * size will be as close to this value as possible. * * @param sizePx Expected media grid size in pixel. * @return [SelectionCreator] for fluent API. */ fun gridExpectedSize(sizePx: Int) = this.apply { selectionSpec.gridExpectedSize = sizePx } /** * Photo thumbnail's scale compared to the View's size. It should be a float value in (0.0,1.0]. * * @param scale Thumbnail's scale in (0.0, 1.0]. Default value is 0.5. * @return [SelectionCreator] for fluent API. */ fun thumbnailScale(scale: Float) = this.apply { require(!(scale <= 0f || scale > 1f)) { "Thumbnail scale must be between (0.0, 1.0]" } selectionSpec.thumbnailScale = scale } /** * Provide an image engine. * There are two built-in image engines: * And you can implement your own image engine. * * @param imageEngine [ImageEngine] * @return [SelectionCreator] for fluent API. */ fun imageEngine(imageEngine: ImageEngine) = this.apply { selectionSpec.imageEngine = imageEngine selectionSpec.imageEngine?.init(matisse.activity?.applicationContext!!) } /** * Whether to support crop * If this value is set true, it will support function crop. * @param crop Whether to support crop or not. Default value is false; * @return [SelectionCreator] for fluent API. */ fun isCrop(crop: Boolean) = this.apply { selectionSpec.isCrop = crop } /** * isCircleCrop * default is RECTANGLE CROP */ fun isCircleCrop(isCircle: Boolean) = this.apply { selectionSpec.isCircleCrop = isCircle } /** * provide file to save image after crop */ fun cropCacheFolder(cropCacheFolder: File) = this.apply { selectionSpec.cropCacheFolder = cropCacheFolder } /** * Set listener for callback immediately when user select or unselect something. * * It's a redundant API with [Matisse.obtainResult], * we only suggest you to use this API when you need to do something immediately. * * @param listener [OnSelectedListener] * @return [SelectionCreator] for fluent API. */ fun setOnSelectedListener(listener: OnSelectedListener?) = this.apply { selectionSpec.onSelectedListener = listener } /** * Set listener for callback immediately when user check or uncheck original. * * @param listener [OnSelectedListener] * @return [SelectionCreator] for fluent API. */ fun setOnCheckedListener(listener: OnCheckedListener?) = this.apply { selectionSpec.onCheckedListener = listener } /** * set notice type for matisse */ fun setNoticeConsumer( noticeConsumer: ((context: Context, noticeType: Int, title: String, message: String) -> Unit)? ) = this.apply { selectionSpec.noticeEvent = noticeConsumer } /** * set Status Bar */ fun setStatusBarFuture(statusBarFunction: ((params: BaseActivity, view: View?) -> Unit)?) = this.apply { selectionSpec.statusBarFuture = statusBarFunction } /** * set last choose pictures ids * id is cursor id. not support crop picture * 预选中上次带回的图片 * 注:暂时无法保持预选中图片的顺序 */ fun setLastChoosePicturesIdOrUri(list: ArrayList?) = this.apply { selectionSpec.lastChoosePictureIdsOrUris = list } /** * Start to select media and wait for result. * * @param requestCode Identity of the request Activity or Fragment. */ fun forResult(requestCode: Int) { val activity = matisse.activity ?: return val intent = Intent(activity, MatisseActivity::class.java) val fragment = matisse.fragment if (fragment != null) { fragment.startActivityForResult(intent, requestCode) } else { activity.startActivityForResult(intent, requestCode) } } } ================================================ FILE: matisse/src/main/java/com/matisse/engine/ImageEngine.kt ================================================ package com.matisse.engine import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri import android.widget.ImageView /** * Describe : Image loader interface. There are predefined * Created by Leo on 2018/9/6 on 17:01. */ interface ImageEngine { /** * Load thumbnail of a static image resource * * @param context context * @param resize Desired size of the origin image * @param placeholder Placeholder drawable when image is not loaded yet * @param imageView ImageView widget * @param uri Uri of the loaded image */ fun loadThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) /** * Load thumbnail of a static image resource * * @param context context * @param resize Desired size of the origin image * @param placeholder Placeholder drawable when image is not loaded yet * @param imageView ImageView widget * @param uri Uri of the loaded image */ fun loadGifThumbnail( context: Context, resize: Int, placeholder: Drawable?, imageView: ImageView, uri: Uri? ) /** * Load a gif image resource * * @param context context * @param imageView ImageView widget * @param uri Uri of the loaded image */ fun loadImage(context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri?) /** * Load a gif image resource * * @param context context * @param resizeX Desired x-size of the origin image * @param resizeY Desired y-size of the origin image * @param imageView ImageView widget * @param uri Uri of the loaded image */ fun loadGifImage(context: Context, resizeX: Int, resizeY: Int, imageView: ImageView, uri: Uri?) fun cleanMemory(context: Context) fun pause(context: Context) fun resume(context: Context) // 在application的onCreate中初始化 fun init(context: Context) } ================================================ FILE: matisse/src/main/java/com/matisse/entity/Album.kt ================================================ package com.matisse.entity import android.content.Context import android.database.Cursor import android.net.Uri import android.os.Parcel import android.os.Parcelable import com.matisse.R import com.matisse.loader.AlbumLoader class Album() : Parcelable { private var id = "" private var coverUri: Uri? = null private var displayName = "" private var count: Long = 0 private var isCheck = false constructor(parcel: Parcel) : this() { id = parcel.readString() ?: "" coverUri = parcel.readParcelable(Uri::class.java.classLoader) displayName = parcel.readString() ?: "" count = parcel.readLong() isCheck = parcel.readByte() != 0.toByte() } constructor(mCoverUri: Uri?, mDisplayName: String, mCount: Long) : this("-1", mCoverUri, mDisplayName, mCount) constructor(mDisplayName: String, mCount: Long) : this(System.currentTimeMillis().toString(), mDisplayName, mCount, false) constructor(mId: String, mCoverUri: Uri?, mDisplayName: String, mCount: Long) : this() { this.id = mId this.coverUri = mCoverUri this.displayName = mDisplayName this.count = mCount this.isCheck = false } constructor( mId: String, mDisplayName: String, mCount: Long, mIsCheck: Boolean = false ) : this() { this.id = mId this.displayName = mDisplayName this.count = mCount this.isCheck = mIsCheck } fun getId() = id fun getCoverPath() = coverUri fun setCoverPath(path: Uri?) { path?.apply { coverUri = this } } fun getCount() = count fun addCaptureCount() { count++ } fun getDisplayName(context: Context): String { return if (isAll()) { context.getString(R.string.album_name_all) } else displayName } fun isAll() = ALBUM_ID_ALL == id fun isEmpty() = count == 0L fun isChecked() = isCheck override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(id) parcel.writeParcelable(coverUri, 0) parcel.writeString(displayName) parcel.writeLong(count) parcel.writeByte(if (isCheck) 1 else 0) } override fun describeContents() = 0 companion object CREATOR : Parcelable.Creator { const val ALBUM_ID_ALL = (-1).toString() const val ALBUM_NAME_ALL = "All" override fun createFromParcel(parcel: Parcel): Album { return Album(parcel) } override fun newArray(size: Int): Array { return arrayOfNulls(size) } fun valueOf(cursor: Cursor) = Album( cursor.getString(cursor.getColumnIndex(AlbumLoader.BUCKET_ID)), Uri.parse(cursor.getString(cursor.getColumnIndex(AlbumLoader.COLUMN_URI)) ?: ""), cursor.getString(cursor.getColumnIndex(AlbumLoader.BUCKET_DISPLAY_NAME)), cursor.getLong(cursor.getColumnIndex(AlbumLoader.COLUMN_COUNT)) ) } } ================================================ FILE: matisse/src/main/java/com/matisse/entity/CaptureStrategy.kt ================================================ package com.matisse.entity data class CaptureStrategy(var isPublic: Boolean, var authority: String, var directory: String = "") ================================================ FILE: matisse/src/main/java/com/matisse/entity/ConstValue.kt ================================================ package com.matisse.entity import com.matisse.ucrop.UCrop object ConstValue { const val EXTRA_RESULT_SELECTION = "extra_result_selection" const val EXTRA_RESULT_SELECTION_ID = "extra_result_selection_id" const val EXTRA_RESULT_ORIGINAL_ENABLE = "extra_result_original_enable" const val EXTRA_ALBUM = "extra_album" const val EXTRA_ITEM = "extra_item" const val CHECK_STATE = "checkState" const val FOLDER_CHECK_POSITION = "folder_check_position" const val EXTRA_DEFAULT_BUNDLE = "extra_default_bundle" const val EXTRA_RESULT_BUNDLE = "extra_result_bundle" const val EXTRA_RESULT_CROP_BACK_BUNDLE = UCrop.EXTRA_OUTPUT_URI const val EXTRA_RESULT_APPLY = "extra_result_apply" const val STATE_SELECTION = "state_selection" const val STATE_COLLECTION_TYPE = "state_collection_type" const val REQUEST_CODE_PREVIEW = 23 const val REQUEST_CODE_CAPTURE = 24 const val REQUEST_CODE_CROP = 69 // 对应UCrop中的key const val REQUEST_CODE_CROP_ERROR = 96 // 对应UCrop中的key const val REQUEST_CODE_CHOOSE = 26 } ================================================ FILE: matisse/src/main/java/com/matisse/entity/IncapableCause.kt ================================================ package com.matisse.entity import android.content.Context import android.widget.Toast import androidx.annotation.IntDef import androidx.fragment.app.FragmentActivity import com.matisse.internal.entity.SelectionSpec import com.matisse.widget.IncapableDialog class IncapableCause { companion object { const val TOAST = 0x0001 const val DIALOG = 0x0002 const val LOADING = 0x0003 const val NONE = 0x0004 fun handleCause(context: Context, cause: IncapableCause?) { if (cause?.noticeEvent != null) { cause.noticeEvent?.invoke( context, cause.form, cause.title ?: "", cause.message ?: "" ) return } when (cause?.form) { DIALOG -> { IncapableDialog.newInstance(cause.title, cause.message) .show( (context as FragmentActivity).supportFragmentManager, IncapableDialog::class.java.name ) } TOAST -> { Toast.makeText(context, cause.message, Toast.LENGTH_SHORT).show() } LOADING -> { // TODO Leo 2019-12-24 complete loading } } } } @Retention(AnnotationRetention.SOURCE) @IntDef(TOAST, DIALOG, LOADING, NONE) annotation class Form var form = TOAST var title: String? = null var message: String? = null var dismissLoading: Boolean? = null var noticeEvent: (( context: Context, noticeType: Int, title: String, msg: String ) -> Unit)? = null constructor(message: String) : this(TOAST, message) constructor(@Form form: Int, message: String) : this(form, "", message) constructor(@Form form: Int, title: String, message: String) : this(form, title, message, true) constructor(@Form form: Int, title: String, message: String, dismissLoading: Boolean) { this.form = form this.title = title this.message = message this.dismissLoading = dismissLoading this.noticeEvent = SelectionSpec.getInstance().noticeEvent } } ================================================ FILE: matisse/src/main/java/com/matisse/entity/Item.kt ================================================ package com.matisse.entity import android.content.ContentUris import android.database.Cursor import android.net.Uri import android.os.Parcelable import android.provider.MediaStore import com.matisse.MimeTypeManager import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.android.parcel.Parcelize @Parcelize class Item( var id: Long, private var mimeType: String, var size: Long = 0, var duration: Long = 0, var positionInList: Int = -1 ) : Parcelable { companion object { const val ITEM_ID_CAPTURE: Long = -1 const val ITEM_DISPLAY_NAME_CAPTURE = "Capture" // * 注:资源文件size单位为字节byte fun valueOf(cursor: Cursor?, positionInList: Int = -1) = cursor?.let { Item( it.getLong(it.getColumnIndex(MediaStore.Files.FileColumns._ID)), it.getString(it.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)), it.getLong(it.getColumnIndex(MediaStore.MediaColumns.SIZE)), it.getLong(it.getColumnIndex("duration")), positionInList ) } } @IgnoredOnParcel private var uri: Uri init { val contentUri = when { isImage() -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI isVideo() -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI else -> MediaStore.Files.getContentUri("external") } uri = ContentUris.withAppendedId(contentUri, id) } fun isImage() = MimeTypeManager.isImage(mimeType) fun isGif() = MimeTypeManager.isGif(mimeType) fun isVideo() = MimeTypeManager.isVideo(mimeType) fun getContentUri() = uri fun isCapture() = id == ITEM_ID_CAPTURE override fun describeContents() = 0 override fun equals(other: Any?): Boolean { if (other !is Item) return false val otherItem = other as Item? return ((id == otherItem?.id && (mimeType == otherItem.mimeType)) && (uri == otherItem.uri) && size == otherItem.size && duration == otherItem.duration) } override fun hashCode(): Int { var result = 1 result = 31 * result + mimeType.hashCode() result = 31 * result + uri.hashCode() result = 31 * result + size.toString().hashCode() result = 31 * result + duration.toString().hashCode() return result } } ================================================ FILE: matisse/src/main/java/com/matisse/filter/Filter.kt ================================================ package com.matisse.filter import android.content.Context import com.matisse.MimeType import com.matisse.MimeTypeManager import com.matisse.entity.IncapableCause import com.matisse.entity.Item /** * Describe : Filter for choosing a {@link Item}. You can add multiple Filters through * {@link SelectionCreator #addFilter(Filter)}. * Created by Leo on 2018/9/4 on 16:12. */ abstract class Filter { companion object { // Convenient constant for a minimum value const val MIN = 0 // Convenient constant for a maximum value const val MAX = Int.MAX_VALUE // Convenient constant for 1024 const val K = 1024 } // Against what mime types this filter applies abstract fun constraintTypes(): Set /** * Invoked for filtering each item * * @return null if selectable, {@link IncapableCause} if not selectable. */ abstract fun filter(context: Context, item: Item?): IncapableCause? // Whether an {@link Item} need filtering open fun needFiltering(context: Context, item: Item?): Boolean { constraintTypes().forEach { if (MimeTypeManager.checkType(context, item?.getContentUri(), it.getValue()) ) return true } return false } } ================================================ FILE: matisse/src/main/java/com/matisse/internal/entity/SelectionSpec.kt ================================================ package com.matisse.internal.entity import android.content.Context import android.content.pm.ActivityInfo import android.view.View import androidx.annotation.StyleRes import com.matisse.MimeType import com.matisse.MimeTypeManager import com.matisse.R import com.matisse.engine.ImageEngine import com.matisse.entity.CaptureStrategy import com.matisse.entity.Item import com.matisse.filter.Filter import com.matisse.listener.OnCheckedListener import com.matisse.listener.OnSelectedListener import com.matisse.ui.activity.BaseActivity import java.io.File /** * Describe : Builder to get config values * Created by Leo on 2018/8/29 on 14:54. */ class SelectionSpec { var mimeTypeSet: Set? = null var mediaTypeExclusive = false // 设置单种/多种媒体资源选择 默认支持多种 var filters: MutableList? = null var maxSelectable = 1 var maxImageSelectable = 0 var maxVideoSelectable = 0 var thumbnailScale = 0.5f var countable = false var capture = false var gridExpectedSize = 0 var spanCount = 3 var captureStrategy: CaptureStrategy? = null @StyleRes var themeId = R.style.Matisse_Default var orientation = 0 var originalable = false var originalMaxSize = 0 var imageEngine: ImageEngine? = null var onSelectedListener: OnSelectedListener? = null var onCheckedListener: OnCheckedListener? = null var isCrop = false // 裁剪 var isCircleCrop = false // 裁剪框的形状 var cropCacheFolder: File? = null // 裁剪后文件保存路径 var hasInited = false // 是否初始化完成 // 库内提示具体回调 var noticeEvent: (( context: Context, noticeType: Int, title: String, msg: String ) -> Unit)? = null // 状态栏处理回调 var statusBarFuture: ((params: BaseActivity, view: View?) -> Unit)? = null var lastChoosePictureIdsOrUris: ArrayList? = null // 上次选中的图片Id class InstanceHolder { companion object { val INSTANCE: SelectionSpec = SelectionSpec() } } companion object { fun getInstance() = InstanceHolder.INSTANCE fun getCleanInstance(): SelectionSpec { val selectionSpec = getInstance() selectionSpec.reset() return selectionSpec } } private fun reset() { mimeTypeSet = null mediaTypeExclusive = false themeId = R.style.Matisse_Default orientation = 0 countable = false maxSelectable = 1 maxImageSelectable = 0 maxVideoSelectable = 0 filters = null capture = false captureStrategy = null spanCount = 3 gridExpectedSize = 0 thumbnailScale = 0.5f imageEngine = null hasInited = true // crop isCrop = false isCircleCrop = false // return original setting originalable = false originalMaxSize = Integer.MAX_VALUE noticeEvent = null statusBarFuture = null lastChoosePictureIdsOrUris = null } // 是否可计数 fun isCountable() = countable && !isSingleChoose() // 是否可单选 fun isSingleChoose() = maxSelectable == 1 || (maxImageSelectable == 1 && maxVideoSelectable == 1) // 是否可裁剪 fun openCrop() = isCrop && isSingleChoose() fun isSupportCrop(item: Item?) = item != null && item.isImage() && !item.isGif() // 是否单一资源选择方式 fun isMediaTypeExclusive() = mediaTypeExclusive && (maxImageSelectable + maxVideoSelectable == 0) fun onlyShowImages() = if (mimeTypeSet != null) MimeTypeManager.ofImage().containsAll(mimeTypeSet!!) else false fun onlyShowVideos() = if (mimeTypeSet != null) MimeTypeManager.ofVideo().containsAll(mimeTypeSet!!) else false fun singleSelectionModeEnabled() = !countable && isSingleChoose() fun needOrientationRestriction() = orientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } ================================================ FILE: matisse/src/main/java/com/matisse/listener/OnCheckedListener.kt ================================================ package com.matisse.listener /** * Created by Lijianyou on 2018-09-07. * @author Lijianyou */ interface OnCheckedListener { fun onCheck(isChecked: Boolean) } ================================================ FILE: matisse/src/main/java/com/matisse/listener/OnSelectedListener.kt ================================================ package com.matisse.listener import android.net.Uri /** * Created by Lijianyou on 2018-09-07. * @author Lijianyou */ interface OnSelectedListener { /** * @param uriList the selected item [Uri] list. * @param pathList the selected item file path list. */ fun onSelected(uriList: List, pathList: List) } ================================================ FILE: matisse/src/main/java/com/matisse/loader/AlbumLoader.kt ================================================ package com.matisse.loader import android.content.ContentUris import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.database.MergeCursor import android.net.Uri import android.provider.MediaStore import androidx.loader.content.CursorLoader import com.matisse.MimeTypeManager import com.matisse.entity.Album import com.matisse.internal.entity.SelectionSpec import com.matisse.utils.Platform.beforeAndroidTen import java.util.* /** * Describe : Load all albums(group by bucket_id) into a single cursor * Created by Leo on 2018/8/29 on 14:28. */ class AlbumLoader(context: Context, selection: String, selectionArgs: Array) : CursorLoader( context, QUERY_URI, if (beforeAndroidTen()) PROJECTION else PROJECTION_29, selection, selectionArgs, BUCKET_ORDER_BY ) { companion object { const val COLUMN_COUNT = "count" private val QUERY_URI = MediaStore.Files.getContentUri("external") const val BUCKET_ID = "bucket_id" const val BUCKET_DISPLAY_NAME = "bucket_display_name" private const val BUCKET_ORDER_BY = "datetaken DESC" const val COLUMN_URI = "uri" val COLUMNS = arrayOf( MediaStore.Files.FileColumns._ID, BUCKET_ID, BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, COLUMN_URI, COLUMN_COUNT ) val PROJECTION = arrayOf( MediaStore.Files.FileColumns._ID, BUCKET_ID, BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, "COUNT(*) AS $COLUMN_COUNT" ) private val PROJECTION_29 = arrayOf( MediaStore.Files.FileColumns._ID, BUCKET_ID, BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE ) private const val SELECTION = "(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "=? " + "OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?) " + "AND " + MediaStore.MediaColumns.SIZE + ">0) GROUP BY (" + BUCKET_ID private const val SELECTION_29 = ( "(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "=? OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?) AND " + MediaStore.MediaColumns.SIZE + ">0") private val SELECTION_ARGS = arrayOf( MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() ) private const val SELECTION_FOR_SINGLE_MEDIA_TYPE = MediaStore.Files.FileColumns.MEDIA_TYPE + "=? AND " + MediaStore.MediaColumns.SIZE + ">0) GROUP BY (" + BUCKET_ID private const val SELECTION_FOR_SINGLE_MEDIA_TYPE_29 = ( MediaStore.Files.FileColumns.MEDIA_TYPE + "=? AND " + MediaStore.MediaColumns.SIZE + ">0") private fun getSelectionArgsForSingleMediaType(mediaType: Int) = arrayOf(mediaType.toString()) fun newInstance(context: Context): CursorLoader { var selection = if (beforeAndroidTen()) SELECTION_FOR_SINGLE_MEDIA_TYPE else SELECTION_FOR_SINGLE_MEDIA_TYPE_29 val selectionArgs: Array when { SelectionSpec.getInstance().onlyShowImages() -> selectionArgs = getSelectionArgsForSingleMediaType(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) SelectionSpec.getInstance().onlyShowVideos() -> selectionArgs = getSelectionArgsForSingleMediaType(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) else -> { selection = if (beforeAndroidTen()) SELECTION else SELECTION_29 selectionArgs = SELECTION_ARGS } } return AlbumLoader(context, selection, selectionArgs) } } override fun loadInBackground(): Cursor? { val albums = super.loadInBackground() val allAlbum = MatrixCursor(COLUMNS) return if (beforeAndroidTen()) loadBelowAndroidQ(albums, allAlbum) else loadAboveAndroidQ(albums, allAlbum) } private fun loadBelowAndroidQ(albums: Cursor?, allAlbum: MatrixCursor): MergeCursor { var totalCount = 0 var allAlbumCoverUri: Uri? = null val otherAlbums = MatrixCursor(COLUMNS) albums?.apply { while (moveToNext()) { val fileId = getLong(getColumnIndex(MediaStore.Files.FileColumns._ID)) val bucketId = getLong(getColumnIndex(BUCKET_ID)) val bucketDisplayName = getString(getColumnIndex(BUCKET_DISPLAY_NAME)) val mimeType = getString(getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)) val uri = getUri(albums) val count = getInt(getColumnIndex(COLUMN_COUNT)) otherAlbums.addRow( arrayOf( fileId, bucketId, bucketDisplayName, mimeType, uri.toString(), count.toString() ) ) totalCount += count } if (albums.moveToFirst()) allAlbumCoverUri = getUri(albums) } allAlbumAddRow(allAlbumCoverUri, totalCount, allAlbum) return MergeCursor(arrayOf(allAlbum, otherAlbums)) } private fun loadAboveAndroidQ(albums: Cursor?, allAlbum: MatrixCursor): MergeCursor { var totalCount = 0 var allAlbumCoverUri: Uri? = null val otherAlbums = MatrixCursor(COLUMNS) // Pseudo GROUP BY val countMap = hashMapOf() albums?.apply { while (moveToNext()) { val bucketId = getLong(getColumnIndex(BUCKET_ID)) var count: Long? = countMap[bucketId] if (count == null) count = 1L else count++ countMap[bucketId] = count } if (moveToFirst()) { allAlbumCoverUri = getUri(this) val done = HashSet() do { val bucketId = getLong(getColumnIndex(BUCKET_ID)) if (done.contains(bucketId)) continue val fileId = getLong(getColumnIndex(MediaStore.Files.FileColumns._ID)) val bucketDisplayName = getString(getColumnIndex(BUCKET_DISPLAY_NAME)) val mimeType = getString(getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)) val uri = getUri(this) val count = countMap[bucketId] otherAlbums.addRow( arrayOf( fileId.toString(), bucketId.toString(), bucketDisplayName, mimeType, uri.toString(), count.toString() ) ) done.add(bucketId) totalCount += count?.toInt() ?: 0 } while (albums.moveToNext()) } } allAlbumAddRow(allAlbumCoverUri, totalCount, allAlbum) return MergeCursor(arrayOf(allAlbum, otherAlbums)) } private fun allAlbumAddRow(allAlbumCoverUri: Uri?, totalCount: Int, allAlbum: MatrixCursor) { val row: Array = arrayOf( Album.ALBUM_ID_ALL, Album.ALBUM_ID_ALL, Album.ALBUM_NAME_ALL, null, allAlbumCoverUri?.toString(), totalCount.toString() ) allAlbum.addRow(row) } private fun getUri(cursor: Cursor): Uri { val id = cursor.getLong(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)) val mimeType = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)) ?: "" val contentUri = when { MimeTypeManager.isImage(mimeType) -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI MimeTypeManager.isVideo(mimeType) -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI else -> MediaStore.Files.getContentUri("external") } return ContentUris.withAppendedId(contentUri, id) } override fun onContentChanged() { // FIXME a dirty way to fix loading multiple times } } ================================================ FILE: matisse/src/main/java/com/matisse/loader/AlbumMediaLoader.kt ================================================ package com.matisse.loader import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.database.MergeCursor import android.provider.MediaStore import androidx.loader.content.CursorLoader import com.matisse.entity.Album import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.utils.MediaStoreCompat /** * Load images and videos into a single cursor. * Created by Leo on 2018/9/4 on 19:53. */ class AlbumMediaLoader( context: Context, selection: String, selectionArgs: Array, capture: Boolean ) : CursorLoader(context, QUERY_URI, PROJECTION, selection, selectionArgs, ORDER_BY) { private var enableCapture = false init { enableCapture = capture } companion object { private val QUERY_URI = MediaStore.Files.getContentUri("external") val PROJECTION = arrayOf( MediaStore.Files.FileColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.SIZE, "duration" ) private const val SELECTION_ALL = ("(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "=? OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?) AND " + MediaStore.MediaColumns.SIZE + ">0") private val SELECTION_ALL_ARGS = arrayOf( MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() ) // =========================================================== // === params for album ALL && showSingleMediaType: true === private const val SELECTION_ALL_FOR_SINGLE_MEDIA_TYPE = ( MediaStore.Files.FileColumns.MEDIA_TYPE + "=?" + " AND " + MediaStore.MediaColumns.SIZE + ">0") // === params for ordinary album && showSingleMediaType: false === private const val SELECTION_ALBUM = ( "(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?" + " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?)" + " AND " + " bucket_id=?" + " AND " + MediaStore.MediaColumns.SIZE + ">0") private fun getSelectionAlbumArgs(albumId: String): Array { return arrayOf( MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(), albumId ) } // =============================================================== // === params for ordinary album && showSingleMediaType: true === private const val SELECTION_ALBUM_FOR_SINGLE_MEDIA_TYPE = ( MediaStore.Files.FileColumns.MEDIA_TYPE + "=? AND bucket_id=? AND " + MediaStore.MediaColumns.SIZE + ">0") // =============================================================== private const val ORDER_BY = MediaStore.Images.Media.DATE_TAKEN + " DESC" fun newInstance(context: Context, album: Album, capture: Boolean): CursorLoader { val selection: String val selectionArgs: Array val enableCapture: Boolean if (album.isAll()) { when { SelectionSpec.getInstance().onlyShowImages() -> { selection = SELECTION_ALL_FOR_SINGLE_MEDIA_TYPE selectionArgs = arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString()) } SelectionSpec.getInstance().onlyShowVideos() -> { selection = SELECTION_ALL_FOR_SINGLE_MEDIA_TYPE selectionArgs = arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()) } else -> { selection = SELECTION_ALL selectionArgs = SELECTION_ALL_ARGS } } enableCapture = capture } else { when { SelectionSpec.getInstance().onlyShowImages() -> { selection = SELECTION_ALBUM_FOR_SINGLE_MEDIA_TYPE selectionArgs = arrayOf( MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), album.getId() ) } SelectionSpec.getInstance().onlyShowVideos() -> { selection = SELECTION_ALBUM_FOR_SINGLE_MEDIA_TYPE selectionArgs = arrayOf( MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(), album.getId() ) } else -> { selection = SELECTION_ALBUM selectionArgs = getSelectionAlbumArgs(album.getId()) } } enableCapture = false } return AlbumMediaLoader(context, selection, selectionArgs, enableCapture) } } override fun loadInBackground(): Cursor? { val result = super.loadInBackground() if (!enableCapture || !MediaStoreCompat.hasCameraFeature(context)) { return result } val dummy = MatrixCursor(PROJECTION) dummy.addRow(arrayOf(Item.ITEM_ID_CAPTURE, Item.ITEM_DISPLAY_NAME_CAPTURE, "", 0, 0)) return MergeCursor(arrayOf(dummy, result!!)) } override fun onContentChanged() { // FIXME a dirty way to fix loading multiple times } } ================================================ FILE: matisse/src/main/java/com/matisse/model/AlbumCallbacks.kt ================================================ package com.matisse.model import android.database.Cursor interface AlbumCallbacks { fun onAlbumStart() fun onAlbumLoad(cursor: Cursor) fun onAlbumReset() } ================================================ FILE: matisse/src/main/java/com/matisse/model/AlbumCollection.kt ================================================ package com.matisse.model import android.content.Context import android.database.Cursor import android.os.Bundle import androidx.fragment.app.FragmentActivity import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import com.matisse.loader.AlbumLoader import java.lang.ref.WeakReference class AlbumCollection : LoaderManager.LoaderCallbacks { companion object { const val LOADER_ID = 1 const val STATE_CURRENT_SELECTION = "state_current_selection" } private var context: WeakReference? = null private var loaderManager: LoaderManager? = null private var callbacks: AlbumCallbacks? = null private var currentSelection = 0 private var loadFinished = false override fun onCreateLoader(id: Int, args: Bundle?): Loader { val context = context?.get() loadFinished = false return AlbumLoader.newInstance(context!!) } override fun onLoadFinished(loader: Loader, data: Cursor?) { if (context?.get() == null || data == null) return if (!loadFinished) { loadFinished = true callbacks?.onAlbumLoad(data) } } override fun onLoaderReset(loader: Loader) { if (context?.get() == null) return callbacks?.onAlbumReset() } fun onCreate(activity: FragmentActivity, callbacks: AlbumCallbacks) { context = WeakReference(activity) loaderManager = LoaderManager.getInstance(activity) this.callbacks = callbacks } fun onRestoreInstanceState(saveInstanceState: Bundle) { currentSelection = saveInstanceState.getInt(STATE_CURRENT_SELECTION) } fun onSaveInstanceState(outState: Bundle?) { outState?.putInt(STATE_CURRENT_SELECTION, currentSelection) } fun onDestroy() { loaderManager?.destroyLoader(LOADER_ID) if (callbacks != null) callbacks = null } @Synchronized fun loadAlbums() { loadFinished = false loaderManager?.initLoader(LOADER_ID, null, this) } fun getCurrentSelection() = currentSelection fun setStateCurrentSelection(currentSelection: Int) { this.currentSelection = currentSelection } } ================================================ FILE: matisse/src/main/java/com/matisse/model/AlbumMediaCollection.kt ================================================ package com.matisse.model import android.content.Context import android.database.Cursor import android.os.Bundle import androidx.fragment.app.FragmentActivity import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import com.matisse.entity.Album import com.matisse.loader.AlbumMediaLoader import java.lang.ref.WeakReference class AlbumMediaCollection : LoaderManager.LoaderCallbacks { companion object { const val LOADER_ID = 2 const val ARGS_ALBUM = "args_album" const val ARGS_ENABLE_CAPTURE = "args_enable_capture" } private var context: WeakReference? = null private var loaderManager: LoaderManager? = null private var callbacks: AlbumCallbacks? = null fun onCreate(context: FragmentActivity, callbacks: AlbumCallbacks) { this.context = WeakReference(context) loaderManager = LoaderManager.getInstance(context) this.callbacks = callbacks } fun onDestroy() { loaderManager?.destroyLoader(LOADER_ID) if (callbacks != null) callbacks = null } fun load(target: Album) { load(target, false) } fun load(target: Album, enableCapture: Boolean) { val args = Bundle() args.putParcelable(ARGS_ALBUM, target) args.putBoolean(ARGS_ENABLE_CAPTURE, enableCapture) loaderManager?.initLoader(LOADER_ID, args, this) } override fun onCreateLoader(id: Int, args: Bundle?): Loader { val content = context?.get() val album = args?.getParcelable(ARGS_ALBUM) return AlbumMediaLoader.newInstance( content!!, album!!, album.isAll() && args.getBoolean(ARGS_ENABLE_CAPTURE, false) ) } override fun onLoadFinished(loader: Loader, data: Cursor?) { if (context?.get() == null) return callbacks?.onAlbumLoad(data!!) } override fun onLoaderReset(loader: Loader) { if (context?.get() == null) return callbacks?.onAlbumReset() } } ================================================ FILE: matisse/src/main/java/com/matisse/model/SelectedItemCollection.kt ================================================ package com.matisse.model import android.content.Context import android.content.res.Resources import android.net.Uri import android.os.Bundle import com.matisse.R import com.matisse.entity.ConstValue.STATE_COLLECTION_TYPE import com.matisse.entity.ConstValue.STATE_SELECTION import com.matisse.entity.IncapableCause import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.utils.PhotoMetadataUtils import com.matisse.utils.getPath import com.matisse.widget.CheckView import java.util.* import kotlin.collections.ArrayList class SelectedItemCollection(private var context: Context) { companion object { /** * Empty collection */ const val COLLECTION_UNDEFINED = 0x00 /** * Collection only with images */ const val COLLECTION_IMAGE = 0x01 /** * Collection only with videos */ const val COLLECTION_VIDEO = 0x02 /** * Collection with images and videos. */ const val COLLECTION_MIXED = COLLECTION_IMAGE or COLLECTION_VIDEO } private lateinit var items: LinkedHashSet private var imageItems: LinkedHashSet? = null private var videoItems: LinkedHashSet? = null private var collectionType = COLLECTION_UNDEFINED private val spec: SelectionSpec = SelectionSpec.getInstance() fun onCreate(bundle: Bundle?) { if (bundle == null) { items = linkedSetOf() } else { val saved = bundle.getParcelableArrayList(STATE_SELECTION) items = LinkedHashSet(saved!!) initImageOrVideoItems() collectionType = bundle.getInt(STATE_COLLECTION_TYPE, COLLECTION_UNDEFINED) } } /** * 根据混合选择模式,初始化图片与视频集合 */ private fun initImageOrVideoItems() { if (spec.isMediaTypeExclusive()) return items.forEach { addImageOrVideoItem(it) } } fun onSaveInstanceState(outState: Bundle?) { outState?.putParcelableArrayList(STATE_SELECTION, ArrayList(items)) outState?.putInt(STATE_COLLECTION_TYPE, collectionType) } fun getDataWithBundle() = Bundle().run { putParcelableArrayList(STATE_SELECTION, ArrayList(items)) putInt(STATE_COLLECTION_TYPE, collectionType) this } fun setDefaultSelection(uris: List) { items.addAll(uris) } private fun resetType() { if (items.size == 0) { collectionType = COLLECTION_UNDEFINED } else { if (collectionType == COLLECTION_MIXED) refineCollectionType() } } fun overwrite(items: ArrayList, collectionType: Int) { this.collectionType = if (items.size == 0) COLLECTION_UNDEFINED else collectionType this.items.clear() this.items.addAll(items) } fun asList() = ArrayList(items) fun asListOfUri(): List { val uris = arrayListOf() for (item in items) { uris.add(item.getContentUri()) } return uris } fun asListOfString(): List { val paths = ArrayList() items.forEach { val path = getPath(context, it.getContentUri()) if (path != null) paths.add(path) } return paths } fun isAcceptable(item: Item?): IncapableCause? { if (maxSelectableReached(item)) { val maxSelectable = currentMaxSelectable(item) val maxSelectableTips = currentMaxSelectableTips(item) val cause = try { context.getString(maxSelectableTips, maxSelectable) } catch (e: Resources.NotFoundException) { context.getString(maxSelectableTips, maxSelectable) } catch (e: NoClassDefFoundError) { context.getString(maxSelectableTips, maxSelectable) } return IncapableCause(cause) } else if (typeConflict(item)) { return IncapableCause(context.getString(R.string.error_type_conflict)) } return PhotoMetadataUtils.isAcceptable(context, item) } private fun currentMaxSelectableTips(item: Item?): Int { if (!spec.isMediaTypeExclusive()) { if (item?.isImage() == true) { return R.string.error_over_count_of_image } else if (item?.isVideo() == true) { return R.string.error_over_count_of_video } } return R.string.error_over_count } fun maxSelectableReached(item: Item?): Boolean { if (!spec.isMediaTypeExclusive()) { if (item?.isImage() == true) { return spec.maxImageSelectable == imageItems?.size } else if (item?.isVideo() == true) { return spec.maxVideoSelectable == videoItems?.size } } return spec.maxSelectable == items.size } // depends private fun currentMaxSelectable(item: Item?): Int { if (!spec.isMediaTypeExclusive()) { if (item?.isImage() == true) { return spec.maxImageSelectable } else if (item?.isVideo() == true) { return spec.maxVideoSelectable } } return spec.maxSelectable } fun getCollectionType() = collectionType fun isEmpty() = items.isEmpty() fun isSelected(item: Item?) = items.contains(item) fun count() = items.size fun items() = items.toList() /** * 注: * 此处取的是item在选中集合中的序号, * 所以不需区分混合选择或单独选择 */ fun checkedNumOf(item: Item?): Int { val index = ArrayList(items).indexOf(item) return if (index == -1) CheckView.UNCHECKED else index + 1 } /** * 根据item集合数据设置collectionType */ private fun refineCollectionType() { val hasImage = imageItems != null && imageItems?.size ?: 0 > 0 val hasVideo = videoItems != null && videoItems?.size ?: 0 > 0 collectionType = if (hasImage && hasVideo) { COLLECTION_MIXED } else if (hasImage) { COLLECTION_IMAGE } else if (hasVideo) { COLLECTION_VIDEO } else { COLLECTION_UNDEFINED } } /** * Determine whether there will be conflict media types. A user can only select images and videos at the same time * while [SelectionSpec.mediaTypeExclusive] is set to false. */ private fun typeConflict(item: Item?) = spec.isMediaTypeExclusive() && ((item?.isImage() == true && (collectionType == COLLECTION_VIDEO || collectionType == COLLECTION_MIXED)) || (item?.isVideo() == true && (collectionType == COLLECTION_IMAGE || collectionType == COLLECTION_MIXED))) fun add(item: Item?): Boolean { if (typeConflict(item)) { throw IllegalArgumentException("Can't select images and videos at the same time.") } if (item == null) return false val added = items.add(item) addImageOrVideoItem(item) if (added) { when (collectionType) { COLLECTION_UNDEFINED -> { if (item.isImage()) { collectionType = COLLECTION_IMAGE } else if (item.isVideo()) { collectionType = COLLECTION_VIDEO } } COLLECTION_IMAGE, COLLECTION_VIDEO -> { if ((item.isImage() && collectionType == COLLECTION_VIDEO) || item.isVideo() && collectionType == COLLECTION_IMAGE ) { collectionType = COLLECTION_MIXED } } } } return added } private fun addImageOrVideoItem(item: Item) { if (item.isImage()) { if (imageItems == null) imageItems = linkedSetOf() imageItems?.add(item) } else if (item.isVideo()) { if (videoItems == null) videoItems = linkedSetOf() videoItems?.add(item) } } private fun removeImageOrVideoItem(item: Item) { if (item.isImage()) { imageItems?.remove(item) } else if (item.isVideo()) { videoItems?.remove(item) } } fun remove(item: Item?): Boolean { if (item == null) return false val removed = items.remove(item) removeImageOrVideoItem(item) if (removed) resetType() return removed } fun removeAll() { items.clear() imageItems?.clear() videoItems?.clear() resetType() } } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/Compat.java ================================================ /******************************************************************************* * 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 com.matisse.photoview; import android.annotation.TargetApi; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.view.View; class Compat { private static final int SIXTY_FPS_INTERVAL = 1000 / 60; public static void postOnAnimation(View view, Runnable runnable) { if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { postOnAnimationJellyBean(view, runnable); } else { view.postDelayed(runnable, SIXTY_FPS_INTERVAL); } } @TargetApi(16) private static void postOnAnimationJellyBean(View view, Runnable runnable) { view.postOnAnimation(runnable); } } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/CustomGestureDetector.java ================================================ /******************************************************************************* * 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 com.matisse.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 int mActivePointerId = INVALID_POINTER_ID; private int mActivePointerIndex = 0; private final ScaleGestureDetector mDetector; private VelocityTracker mVelocityTracker; private boolean mIsDragging; private float mLastTouchX; private float mLastTouchY; private final float mTouchSlop; private final float mMinimumVelocity; private OnGestureListener mListener; 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; } } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnGestureListener.java ================================================ /******************************************************************************* * 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 com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnMatrixChangedListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnOutsidePhotoTapListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnPhotoTapListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnScaleChangedListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnSingleFlingListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnViewDragListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/OnViewTapListener.java ================================================ package com.matisse.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); } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/PhotoView.java ================================================ /******************************************************************************* * 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 com.matisse.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 androidx.appcompat.widget.AppCompatImageView; /** * A zoomable {@link AppCompatImageView}. See {@link PhotoViewAttacher} for most of the details on how the zooming * is accomplished */ public class PhotoView extends AppCompatImageView { private PhotoViewAttacher attacher; private ScaleType pendingScaleType; 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 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 setScaleType(ScaleType scaleType) { if (attacher == null) { pendingScaleType = scaleType; } else { attacher.setScaleType(scaleType); } } @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 float getMediumScale() { return attacher.getMediumScale(); } public float getMaximumScale() { return attacher.getMaximumScale(); } public float getScale() { return attacher.getScale(); } public void setAllowParentInterceptOnEdge(boolean allow) { attacher.setAllowParentInterceptOnEdge(allow); } public void setMinimumScale(float minimumScale) { attacher.setMinimumScale(minimumScale); } public void setMediumScale(float mediumScale) { attacher.setMediumScale(mediumScale); } public void setMaximumScale(float maximumScale) { attacher.setMaximumScale(maximumScale); } 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) { attacher.setScale(scale); } 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); } } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/PhotoViewAttacher.java ================================================ /******************************************************************************* * 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 com.matisse.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 {@link ImageView} and still * gain the functionality that {@link PhotoView} offers */ public class PhotoViewAttacher implements View.OnTouchListener, View.OnLayoutChangeListener { private static float DEFAULT_MAX_SCALE = 3.0f; private static float DEFAULT_MID_SCALE = 1.75f; private static float DEFAULT_MIN_SCALE = 1.0f; private static int DEFAULT_ZOOM_DURATION = 200; 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 int SINGLE_TOUCH = 1; 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; private ImageView mImageView; // Gesture Detectors private GestureDetector mGestureDetector; private CustomGestureDetector mScaleDragDetector; // 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]; // 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 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 float getMediumScale() { return mMidScale; } public float getMaximumScale() { return mMaxScale; } 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 ScaleType getScaleType() { return mScaleType; } @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 setMinimumScale(float minimumScale) { Util.checkZoomLevels(minimumScale, mMidScale, mMaxScale); mMinScale = minimumScale; } public void setMediumScale(float mediumScale) { Util.checkZoomLevels(mMinScale, mediumScale, mMaxScale); mMidScale = mediumScale; } public void setMaximumScale(float maximumScale) { Util.checkZoomLevels(mMinScale, mMidScale, maximumScale); mMaxScale = maximumScale; } 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) { setScale(scale, false); } 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 void setScaleType(ScaleType scaleType) { if (Util.isSupportedScaleType(scaleType) && scaleType != mScaleType) { mScaleType = scaleType; update(); } } 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); } } } } ================================================ FILE: matisse/src/main/java/com/matisse/photoview/Util.java ================================================ package com.matisse.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; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/PictureMultiCuttingActivity.java ================================================ package com.matisse.ucrop; import android.annotation.TargetApi; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.PorterDuff; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; import android.view.animation.AccelerateInterpolator; import android.widget.FrameLayout; import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.matisse.R; import com.matisse.ucrop.callback.BitmapCropCallback; import com.matisse.ucrop.immersion.CropImmersiveManage; import com.matisse.ucrop.model.AspectRatio; import com.matisse.ucrop.model.CutInfo; import com.matisse.ucrop.util.FileUtils; import com.matisse.ucrop.util.VersionUtils; import com.matisse.ucrop.view.CropImageView; import com.matisse.ucrop.view.GestureCropImageView; import com.matisse.ucrop.view.OverlayView; import com.matisse.ucrop.view.TransformImageView; import com.matisse.ucrop.view.UCropView; import java.io.File; import java.io.FileInputStream; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; import java.util.Locale; /** * Created by Oleksii Shliama (https://github.com/shliama). */ @SuppressWarnings("ConstantConditions") public class PictureMultiCuttingActivity extends AppCompatActivity { public static final int DEFAULT_COMPRESS_QUALITY = 90; public static final Bitmap.CompressFormat DEFAULT_COMPRESS_FORMAT = Bitmap.CompressFormat.PNG; public static final int NONE = 0; public static final int SCALE = 1; public static final int ROTATE = 2; public static final int ALL = 3; @IntDef({NONE, SCALE, ROTATE, ALL}) @Retention(RetentionPolicy.SOURCE) public @interface GestureTypes { } private static final String TAG = "UCropActivity"; private static final int TABS_COUNT = 3; private static final int SCALE_WIDGET_SENSITIVITY_COEFFICIENT = 15000; private static final int ROTATE_WIDGET_SENSITIVITY_COEFFICIENT = 42; private RecyclerView mRecyclerView; private PicturePhotoGalleryAdapter adapter; private String mToolbarTitle; private ArrayList list; // Enables dynamic coloring private int mToolbarColor; private int mStatusBarColor; private int mActiveWidgetColor; private int mToolbarWidgetColor; @ColorInt private int mRootViewBackgroundColor; @DrawableRes private int mToolbarCancelDrawable; @DrawableRes private int mToolbarCropDrawable; private int mLogoColor; private boolean mShowLoader = true; private boolean circleDimmedLayer; private UCropView mUCropView; private GestureCropImageView mGestureCropImageView; private OverlayView mOverlayView; private List mCropAspectRatioViews = new ArrayList<>(); private TextView mTextViewRotateAngle, mTextViewScalePercent; private View mBlockingView; private RelativeLayout uCropMultiplePhotoBox; private Bitmap.CompressFormat mCompressFormat = DEFAULT_COMPRESS_FORMAT; private int mCompressQuality = DEFAULT_COMPRESS_QUALITY; private int[] mAllowedGestures = new int[]{SCALE, ROTATE, ALL}; /** * 是否可拖动裁剪框 */ private boolean isDragFrame; /** * 图片是否可拖动或旋转 */ private boolean scaleEnabled, rotateEnabled, openWhiteStatusBar; private int cutIndex; /** * 是否使用沉浸式,子类复写该方法来确定是否采用沉浸式 * * @return 是否沉浸式,默认true */ @Override public boolean isImmersive() { return true; } /** * 具体沉浸的样式,可以根据需要自行修改状态栏和导航栏的颜色 */ public void immersive() { CropImmersiveManage.immersiveAboveAPI23(this , mStatusBarColor , mToolbarColor , openWhiteStatusBar); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); final Intent intent = getIntent(); getIntentData(intent); if (isImmersive()) { immersive(); } setContentView(R.layout.ucrop_picture_activity_multi_cutting); uCropMultiplePhotoBox = findViewById(R.id.ucrop_mulit_photobox); initLoadCutData(); addPhotoRecyclerView(); setupViews(intent); setInitialState(); addBlockingView(); setImageData(intent); } /** * 装载裁剪数据 */ private void initLoadCutData() { list = (ArrayList) getIntent().getSerializableExtra(UCropMulti.Options.EXTRA_CUT_CROP); // Crop cut list if (list == null || list.size() == 0) { closeActivity(); return; } } /** * 动态添加多图裁剪底部预览图片列表 */ private void addPhotoRecyclerView() { mRecyclerView = new RecyclerView(this); mRecyclerView.setId(R.id.id_recycler); mRecyclerView.setBackgroundColor(ContextCompat.getColor(this, R.color.ucrop_color_widget_background)); RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, dip2px(80)); mRecyclerView.setLayoutParams(lp); LinearLayoutManager mLayoutManager = new LinearLayoutManager(this); mLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL); mRecyclerView.setLayoutManager(mLayoutManager); resetCutDataStatus(); list.get(cutIndex).setCut(true); adapter = new PicturePhotoGalleryAdapter(this, list); mRecyclerView.setAdapter(adapter); adapter.setOnItemClickListener((position, view) -> { if (cutIndex == position) { return; } cutIndex = position; resetCutData(); }); uCropMultiplePhotoBox.addView(mRecyclerView); changeLayoutParams(); FrameLayout uCropFrame = findViewById(R.id.ucrop_frame); ((RelativeLayout.LayoutParams) uCropFrame.getLayoutParams()) .addRule(RelativeLayout.ABOVE, R.id.id_recycler); } /** * 切换裁剪图片 */ private void refreshPhotoRecyclerData() { resetCutDataStatus(); list.get(cutIndex).setCut(true); adapter.notifyDataSetChanged(); uCropMultiplePhotoBox.addView(mRecyclerView); changeLayoutParams(); FrameLayout uCropFrame = findViewById(R.id.ucrop_frame); ((RelativeLayout.LayoutParams) uCropFrame.getLayoutParams()) .addRule(RelativeLayout.ABOVE, R.id.id_recycler); } /** * 重置数据裁剪状态 */ private void resetCutDataStatus() { int size = list.size(); for (int i = 0; i < size; i++) { CutInfo cutInfo = list.get(i); cutInfo.setCut(false); } } @Override public boolean onCreateOptionsMenu(final Menu menu) { getMenuInflater().inflate(R.menu.ucrop_menu_activity, menu); // Change crop & loader menu icons color to match the rest of the UI colors MenuItem menuItemLoader = menu.findItem(R.id.menu_loader); Drawable menuItemLoaderIcon = menuItemLoader.getIcon(); if (menuItemLoaderIcon != null) { try { menuItemLoaderIcon.mutate(); menuItemLoaderIcon.setColorFilter(mToolbarWidgetColor, PorterDuff.Mode.SRC_ATOP); menuItemLoader.setIcon(menuItemLoaderIcon); } catch (IllegalStateException e) { Log.i(TAG, String.format("%s - %s", e.getMessage(), getString(R.string.ucrop_mutate_exception_hint))); } ((Animatable) menuItemLoader.getIcon()).start(); } MenuItem menuItemCrop = menu.findItem(R.id.menu_crop); Drawable menuItemCropIcon = ContextCompat.getDrawable(this, mToolbarCropDrawable); if (menuItemCropIcon != null) { menuItemCropIcon.mutate(); menuItemCropIcon.setColorFilter(mToolbarWidgetColor, PorterDuff.Mode.SRC_ATOP); menuItemCrop.setIcon(menuItemCropIcon); } return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.menu_crop).setVisible(!mShowLoader); menu.findItem(R.id.menu_loader).setVisible(mShowLoader); return super.onPrepareOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.menu_crop) { cropAndSaveImage(); } else if (item.getItemId() == android.R.id.home) { onBackPressed(); } return super.onOptionsItemSelected(item); } @Override public void onBackPressed() { super.onBackPressed(); exitAnimation(); } @Override protected void onStop() { super.onStop(); if (mGestureCropImageView != null) { mGestureCropImageView.cancelAllAnimations(); } } /** * This method extracts all data from the incoming intent and setups views properly. */ private void setImageData(@NonNull Intent intent) { Uri inputUri = intent.getParcelableExtra(UCropMulti.EXTRA_INPUT_URI); Uri outputUri = intent.getParcelableExtra(UCropMulti.EXTRA_OUTPUT_URI); processOptions(intent); if (inputUri != null && outputUri != null) { try { ParcelFileDescriptor parcelFileDescriptor = getContentResolver().openFileDescriptor(inputUri, "r"); FileInputStream inputStream = new FileInputStream(parcelFileDescriptor.getFileDescriptor()); String suffix = FileUtils.extSuffix(inputStream); boolean isGif = FileUtils.isGifForSuffix(suffix); mGestureCropImageView.setRotateEnabled(isGif ? false : rotateEnabled); mGestureCropImageView.setScaleEnabled(isGif ? false : scaleEnabled); mGestureCropImageView.setImageUri(inputUri, outputUri); } catch (Exception e) { setResultError(e); closeActivity(); } } else { setResultError(new NullPointerException(getString(R.string.ucrop_error_input_data_is_absent))); closeActivity(); } } /** * This method extracts {@link UCrop.Options #optionsBundle} from incoming intent * and setups Activity, {@link OverlayView} and {@link CropImageView} properly. */ @SuppressWarnings("deprecation") private void processOptions(@NonNull Intent intent) { // Bitmap compression options String compressionFormatName = intent.getStringExtra(UCropMulti.Options.EXTRA_COMPRESSION_FORMAT_NAME); Bitmap.CompressFormat compressFormat = null; if (!TextUtils.isEmpty(compressionFormatName)) { compressFormat = Bitmap.CompressFormat.valueOf(compressionFormatName); } mCompressFormat = (compressFormat == null) ? DEFAULT_COMPRESS_FORMAT : compressFormat; mCompressQuality = intent.getIntExtra(UCrop.Options.EXTRA_COMPRESSION_QUALITY, PictureMultiCuttingActivity.DEFAULT_COMPRESS_QUALITY); // Gestures options int[] allowedGestures = intent.getIntArrayExtra(UCropMulti.Options.EXTRA_ALLOWED_GESTURES); if (allowedGestures != null && allowedGestures.length == TABS_COUNT) { mAllowedGestures = allowedGestures; } // Crop image view options mGestureCropImageView.setMaxBitmapSize(intent.getIntExtra(UCropMulti.Options.EXTRA_MAX_BITMAP_SIZE, CropImageView.DEFAULT_MAX_BITMAP_SIZE)); mGestureCropImageView.setMaxScaleMultiplier(intent.getFloatExtra(UCropMulti.Options.EXTRA_MAX_SCALE_MULTIPLIER, CropImageView.DEFAULT_MAX_SCALE_MULTIPLIER)); mGestureCropImageView.setImageToWrapCropBoundsAnimDuration(intent.getIntExtra(UCropMulti.Options.EXTRA_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION, CropImageView.DEFAULT_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION)); // Overlay view options mOverlayView.setDragFrame(isDragFrame); mOverlayView.setFreestyleCropEnabled(intent.getBooleanExtra(UCropMulti.Options.EXTRA_FREE_STYLE_CROP, false)); circleDimmedLayer = intent.getBooleanExtra(UCropMulti.Options.EXTRA_CIRCLE_DIMMED_LAYER, OverlayView.DEFAULT_CIRCLE_DIMMED_LAYER); mOverlayView.setDimmedColor(intent.getIntExtra(UCropMulti.Options.EXTRA_DIMMED_LAYER_COLOR, getResources().getColor(R.color.ucrop_color_default_dimmed))); mOverlayView.setCircleDimmedLayer(circleDimmedLayer); mOverlayView.setShowCropFrame(intent.getBooleanExtra(UCropMulti.Options.EXTRA_SHOW_CROP_FRAME, OverlayView.DEFAULT_SHOW_CROP_FRAME)); mOverlayView.setCropFrameColor(intent.getIntExtra(UCropMulti.Options.EXTRA_CROP_FRAME_COLOR, getResources().getColor(R.color.ucrop_color_default_crop_frame))); mOverlayView.setCropFrameStrokeWidth(intent.getIntExtra(UCropMulti.Options.EXTRA_CROP_FRAME_STROKE_WIDTH, getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_frame_stoke_width))); mOverlayView.setShowCropGrid(intent.getBooleanExtra(UCropMulti.Options.EXTRA_SHOW_CROP_GRID, OverlayView.DEFAULT_SHOW_CROP_GRID)); mOverlayView.setCropGridRowCount(intent.getIntExtra(UCropMulti.Options.EXTRA_CROP_GRID_ROW_COUNT, OverlayView.DEFAULT_CROP_GRID_ROW_COUNT)); mOverlayView.setCropGridColumnCount(intent.getIntExtra(UCropMulti.Options.EXTRA_CROP_GRID_COLUMN_COUNT, OverlayView.DEFAULT_CROP_GRID_COLUMN_COUNT)); mOverlayView.setCropGridColor(intent.getIntExtra(UCropMulti.Options.EXTRA_CROP_GRID_COLOR, getResources().getColor(R.color.ucrop_color_default_crop_grid))); mOverlayView.setCropGridStrokeWidth(intent.getIntExtra(UCropMulti.Options.EXTRA_CROP_GRID_STROKE_WIDTH, getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_grid_stoke_width))); // Aspect ratio options float aspectRatioX = intent.getFloatExtra(UCropMulti.EXTRA_ASPECT_RATIO_X, 0); float aspectRatioY = intent.getFloatExtra(UCropMulti.EXTRA_ASPECT_RATIO_Y, 0); int aspectRationSelectedByDefault = intent.getIntExtra(UCropMulti.Options.EXTRA_ASPECT_RATIO_SELECTED_BY_DEFAULT, 0); ArrayList aspectRatioList = intent.getParcelableArrayListExtra(UCropMulti.Options.EXTRA_ASPECT_RATIO_OPTIONS); if (aspectRatioX > 0 && aspectRatioY > 0) { mGestureCropImageView.setTargetAspectRatio(aspectRatioX / aspectRatioY); } else if (aspectRatioList != null && aspectRationSelectedByDefault < aspectRatioList.size()) { mGestureCropImageView.setTargetAspectRatio(aspectRatioList.get(aspectRationSelectedByDefault).getAspectRatioX() / aspectRatioList.get(aspectRationSelectedByDefault).getAspectRatioY()); } else { mGestureCropImageView.setTargetAspectRatio(CropImageView.SOURCE_IMAGE_ASPECT_RATIO); } // Result bitmap max size options int maxSizeX = intent.getIntExtra(UCropMulti.EXTRA_MAX_SIZE_X, 0); int maxSizeY = intent.getIntExtra(UCropMulti.EXTRA_MAX_SIZE_Y, 0); if (maxSizeX > 0 && maxSizeY > 0) { mGestureCropImageView.setMaxResultImageSizeX(maxSizeX); mGestureCropImageView.setMaxResultImageSizeY(maxSizeY); } } private void getIntentData(@NonNull Intent intent) { openWhiteStatusBar = intent.getBooleanExtra(UCrop.Options.EXTRA_UCROP_WIDGET_CROP_OPEN_WHITE_STATUSBAR, false); mStatusBarColor = intent.getIntExtra(UCropMulti.Options.EXTRA_STATUS_BAR_COLOR, ContextCompat.getColor(this, R.color.ucrop_color_statusbar)); mToolbarColor = intent.getIntExtra(UCropMulti.Options.EXTRA_TOOL_BAR_COLOR, ContextCompat.getColor(this, R.color.ucrop_color_toolbar)); if (mToolbarColor == 0) { mToolbarColor = ContextCompat.getColor(this, R.color.ucrop_color_toolbar); } if (mStatusBarColor == 0) { mStatusBarColor = ContextCompat.getColor(this, R.color.ucrop_color_statusbar); } } private void setupViews(@NonNull Intent intent) { scaleEnabled = intent.getBooleanExtra(UCropMulti.Options.EXTRA_SCALE, false); rotateEnabled = intent.getBooleanExtra(UCropMulti.Options.EXTRA_ROTATE, false); // 是否可拖动裁剪框 isDragFrame = intent.getBooleanExtra(UCrop.Options.EXTRA_DRAG_CROP_FRAME, true); mActiveWidgetColor = intent.getIntExtra(UCropMulti.Options.EXTRA_UCROP_COLOR_WIDGET_ACTIVE, ContextCompat.getColor(this, R.color.ucrop_color_widget_active)); mToolbarWidgetColor = intent.getIntExtra(UCropMulti.Options.EXTRA_UCROP_WIDGET_COLOR_TOOLBAR, ContextCompat.getColor(this, R.color.ucrop_color_toolbar_widget)); if (mToolbarWidgetColor == 0) { mToolbarWidgetColor = ContextCompat.getColor(this, R.color.ucrop_color_toolbar_widget); } mToolbarCancelDrawable = intent.getIntExtra(UCropMulti.Options.EXTRA_UCROP_WIDGET_CANCEL_DRAWABLE, R.drawable.ucrop_ic_cross); mToolbarCropDrawable = intent.getIntExtra(UCropMulti.Options.EXTRA_UCROP_WIDGET_CROP_DRAWABLE, R.drawable.ucrop_ic_done); mToolbarTitle = intent.getStringExtra(UCropMulti.Options.EXTRA_UCROP_TITLE_TEXT_TOOLBAR); mToolbarTitle = mToolbarTitle != null ? mToolbarTitle : getResources().getString(R.string.ucrop_label_edit_photo); mLogoColor = intent.getIntExtra(UCropMulti.Options.EXTRA_UCROP_LOGO_COLOR, ContextCompat.getColor(this, R.color.ucrop_color_default_logo)); mRootViewBackgroundColor = intent.getIntExtra(UCropMulti.Options.EXTRA_UCROP_ROOT_VIEW_BACKGROUND_COLOR, ContextCompat.getColor(this, R.color.ucrop_color_crop_background)); setNavBarColor(); setupAppBar(); initiateRootViews(); changeLayoutParams(); } /** * set NavBar Color */ private void setNavBarColor() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { int navBarColor = getIntent().getIntExtra(UCropMulti.EXTRA_NAV_BAR_COLOR, 0); if (navBarColor != 0) { getWindow().setNavigationBarColor(navBarColor); } } } /** * Configures and styles both status bar and toolbar. */ private void setupAppBar() { setStatusBarColor(mStatusBarColor); final Toolbar toolbar = findViewById(R.id.toolbar); // Set all of the Toolbar coloring toolbar.setBackgroundColor(mToolbarColor); toolbar.setTitleTextColor(mToolbarWidgetColor); final TextView toolbarTitle = toolbar.findViewById(R.id.toolbar_title); toolbarTitle.setTextColor(mToolbarWidgetColor); toolbarTitle.setText(mToolbarTitle); // Color buttons inside the Toolbar Drawable stateButtonDrawable = ContextCompat.getDrawable(this, mToolbarCancelDrawable).mutate(); stateButtonDrawable.setColorFilter(mToolbarWidgetColor, PorterDuff.Mode.SRC_ATOP); toolbar.setNavigationIcon(stateButtonDrawable); setSupportActionBar(toolbar); final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayShowTitleEnabled(false); } } private void initiateRootViews() { mUCropView = findViewById(R.id.ucrop); mGestureCropImageView = mUCropView.getCropImageView(); mOverlayView = mUCropView.getOverlayView(); mGestureCropImageView.setTransformImageListener(mImageListener); // ((ImageView) findViewById(R.id.image_view_logo)).setColorFilter(mLogoColor, PorterDuff.Mode.SRC_ATOP); // // findViewById(R.id.ucrop_frame).setBackgroundColor(mRootViewBackgroundColor); } private TransformImageView.TransformImageListener mImageListener = new TransformImageView.TransformImageListener() { @Override public void onRotate(float currentAngle) { setAngleText(currentAngle); } @Override public void onScale(float currentScale) { setScaleText(currentScale); } @Override public void onLoadComplete() { mUCropView.animate().alpha(1).setDuration(300).setInterpolator(new AccelerateInterpolator()); mBlockingView.setClickable(false); mShowLoader = false; supportInvalidateOptionsMenu(); } @Override public void onLoadFailure(@NonNull Exception e) { setResultError(e); closeActivity(); } }; /** * Sets status-bar color for L devices. * * @param color - status-bar color */ @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void setStatusBarColor(@ColorInt int color) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { final Window window = getWindow(); if (window != null) { window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.setStatusBarColor(color); } } } private void setAngleText(float angle) { if (mTextViewRotateAngle != null) { mTextViewRotateAngle.setText(String.format(Locale.getDefault(), "%.1f°", angle)); } } private void setScaleText(float scale) { if (mTextViewScalePercent != null) { mTextViewScalePercent.setText(String.format(Locale.getDefault(), "%d%%", (int) (scale * 100))); } } private void resetRotation() { mGestureCropImageView.postRotate(-mGestureCropImageView.getCurrentAngle()); mGestureCropImageView.setImageToWrapCropBounds(); } private void rotateByAngle(int angle) { mGestureCropImageView.postRotate(angle); mGestureCropImageView.setImageToWrapCropBounds(); } private void setInitialState() { setAllowedGestures(0); } private void setAllowedGestures(int tab) { //mGestureCropImageView.setScaleEnabled(mAllowedGestures[tab] == ALL || mAllowedGestures[tab] == SCALE); //mGestureCropImageView.setRotateEnabled(mAllowedGestures[tab] == ALL || mAllowedGestures[tab] == ROTATE); } /** * Adds view that covers everything below the Toolbar. * When it's clickable - user won't be able to click/touch anything below the Toolbar. * Need to block user input while loading and cropping an image. */ private void addBlockingView() { if (mBlockingView == null) { mBlockingView = new View(this); RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); lp.addRule(RelativeLayout.BELOW, R.id.toolbar); mBlockingView.setLayoutParams(lp); mBlockingView.setClickable(true); } uCropMultiplePhotoBox.addView(mBlockingView); } protected void cropAndSaveImage() { mBlockingView.setClickable(true); mShowLoader = true; supportInvalidateOptionsMenu(); mGestureCropImageView.cropAndSaveImage(mCompressFormat, mCompressQuality, false, new BitmapCropCallback() { @Override public void onBitmapCropped(@NonNull Uri resultUri, int offsetX, int offsetY, int imageWidth, int imageHeight) { setResultUri(resultUri, mGestureCropImageView.getTargetAspectRatio(), offsetX, offsetY, imageWidth, imageHeight); } @Override public void onCropFailure(@NonNull Throwable t) { setResultError(t); closeActivity(); } }); } protected void setResultUri(Uri uri, float resultAspectRatio, int offsetX, int offsetY, int imageWidth, int imageHeight) { try { // CutInfo info = list.get(cutIndex); // info.setCutPath(uri.getPath()); // info.setCut(true); // info.setResultAspectRatio(resultAspectRatio); // info.setOffsetX(offsetX); // info.setOffsetY(offsetY); // info.setImageWidth(imageWidth); // info.setImageHeight(imageHeight); cutIndex++; if (cutIndex >= list.size()) { setResult(RESULT_OK, new Intent() .putExtra(UCropMulti.EXTRA_OUTPUT_URI_LIST, list) ); closeActivity(); } else { resetCutData(); } } catch (Exception e) { e.printStackTrace(); } } /** * 重置裁剪参数 */ protected void resetCutData() { uCropMultiplePhotoBox.removeView(mRecyclerView); setContentView(R.layout.ucrop_picture_activity_multi_cutting); uCropMultiplePhotoBox = findViewById(R.id.ucrop_mulit_photobox); Intent intent = getIntent(); Bundle extras = intent.getExtras(); boolean isAndroidQ = VersionUtils.isAndroidQ(); String path = list.get(cutIndex).getPath(); boolean isHttp = FileUtils.isHttp(path); String imgType = getLastImgType(isAndroidQ ? FileUtils.getPath(this, Uri.parse(path)) : path); Uri uri = isHttp || isAndroidQ ? Uri.parse(path) : Uri.fromFile(new File(path)); extras.putParcelable(UCropMulti.EXTRA_INPUT_URI, uri); File file = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED) ? getExternalFilesDir(Environment.DIRECTORY_PICTURES) : getCacheDir(); extras.putParcelable(UCropMulti.EXTRA_OUTPUT_URI, Uri.fromFile(new File(file, FileUtils.getCreateFileName("IMG_") + imgType))); intent.putExtras(extras); refreshPhotoRecyclerData(); setupViews(intent); setImageData(intent); // 预览图 一页5个,裁剪到第6个的时候滚动到最新位置,不然预览图片看不到 if (cutIndex >= 5) { mRecyclerView.scrollToPosition(cutIndex); } changeLayoutParams(); } private void changeLayoutParams() { if (mRecyclerView.getLayoutParams() == null) { return; } ((RelativeLayout.LayoutParams) mRecyclerView.getLayoutParams()) .addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); ((RelativeLayout.LayoutParams) mRecyclerView.getLayoutParams()) .addRule(RelativeLayout.ABOVE, 0); } /** * 获取图片后缀 * * @param path * @return */ public static String getLastImgType(String path) { try { int index = path.lastIndexOf("."); if (index > 0) { String imageType = path.substring(index); switch (imageType) { case ".png": case ".PNG": case ".jpg": case ".jpeg": case ".JPEG": case ".WEBP": case ".bmp": case ".BMP": case ".webp": case ".gif": case ".GIF": return imageType; default: return ".png"; } } else { return ".png"; } } catch (Exception e) { e.printStackTrace(); return ".png"; } } protected void setResultError(Throwable throwable) { setResult(UCropMulti.RESULT_ERROR, new Intent().putExtra(UCropMulti.EXTRA_ERROR, throwable)); } /** * exit activity */ protected void closeActivity() { finish(); exitAnimation(); } protected void exitAnimation() { int exitAnimation = getIntent().getIntExtra(UCropMulti.EXTRA_WINDOW_EXIT_ANIMATION, 0); overridePendingTransition(R.anim.ucrop_anim_fade_in, exitAnimation != 0 ? exitAnimation : R.anim.ucrop_close); } public int dip2px(float dpValue) { return (int) (0.5f + dpValue * getResources().getDisplayMetrics().density); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/PicturePhotoGalleryAdapter.java ================================================ /* * Copyright (C) 2014 pengjianbo(pengjianbosoft@gmail.com), Inc. * * 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 com.matisse.ucrop; import android.content.Context; import android.graphics.Bitmap; import android.net.Uri; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.matisse.R; import com.matisse.ucrop.callback.BitmapLoadShowCallback; import com.matisse.ucrop.model.CutInfo; import com.matisse.ucrop.util.BitmapLoadUtils; import com.matisse.ucrop.util.VersionUtils; import com.matisse.ucrop.util.FileUtils; import java.io.File; import java.util.List; /** * @author:luck * @date:2016-12-31 22:22 * @describe:图片列表 */ public class PicturePhotoGalleryAdapter extends RecyclerView.Adapter { private final int maxImageWidth = 200; private final int maxImageHeight = 220; private Context context; private List list; private LayoutInflater mInflater; private boolean isAndroidQ; public PicturePhotoGalleryAdapter(Context context, List list) { mInflater = LayoutInflater.from(context); this.context = context; this.list = list; this.isAndroidQ = VersionUtils.isAndroidQ(); } public void setData(List list) { this.list = list; notifyDataSetChanged(); } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int position) { View view = mInflater.inflate(R.layout.ucrop_picture_gf_adapter_edit_list, parent, false); return new ViewHolder(view); } @Override public void onBindViewHolder(ViewHolder holder, int position) { String path = ""; CutInfo photoInfo = list.get(position); if (photoInfo != null) { path = photoInfo.getPath(); } if (photoInfo.isCut()) { holder.iv_dot.setVisibility(View.VISIBLE); holder.iv_dot.setImageResource(R.drawable.ucrop_oval_true); } else { holder.iv_dot.setVisibility(View.INVISIBLE); } Uri uri = isAndroidQ ? Uri.parse(path) : Uri.fromFile(new File(path)); holder.tvGif.setVisibility(FileUtils.isGif(photoInfo.getMimeType()) ? View.VISIBLE : View.GONE); BitmapLoadUtils.decodeBitmapInBackground(context, uri, maxImageWidth, maxImageHeight, new BitmapLoadShowCallback() { @Override public void onBitmapLoaded(@NonNull Bitmap bitmap) { if (holder.mIvPhoto != null) { holder.mIvPhoto.setImageBitmap(bitmap); } } @Override public void onFailure(@NonNull Exception bitmapWorkerException) { if (holder.mIvPhoto != null) { holder.mIvPhoto.setImageResource(R.color.ucrop_color_ba3); } } }); holder.itemView.setOnClickListener(v -> { if (listener != null) { listener.onItemClick(holder.getAdapterPosition(), v); } }); } @Override public int getItemCount() { return list != null ? list.size() : 0; } public static class ViewHolder extends RecyclerView.ViewHolder { ImageView mIvPhoto; ImageView iv_dot; TextView tvGif; public ViewHolder(View view) { super(view); mIvPhoto = view.findViewById(R.id.iv_photo); iv_dot = view.findViewById(R.id.iv_dot); tvGif = view.findViewById(R.id.tv_gif); } } private OnItemClickListener listener; public void setOnItemClickListener(OnItemClickListener listener) { this.listener = listener; } public interface OnItemClickListener { void onItemClick(int position, View view); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/UCrop.java ================================================ package com.matisse.ucrop; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import androidx.annotation.AnimRes; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.FloatRange; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.matisse.BuildConfig; import com.matisse.R; import com.matisse.ucrop.model.AspectRatio; import java.util.ArrayList; import java.util.Arrays; import java.util.Locale; /** * Created by Oleksii Shliama (https://github.com/shliama). *

* Builder class to ease Intent setup. */ public class UCrop { public static final int REQUEST_CROP = 69; public static final int RESULT_ERROR = 96; private static final String EXTRA_PREFIX = BuildConfig.APPLICATION_ID; public static final String EXTRA_INPUT_URI = EXTRA_PREFIX + ".InputUri"; public static final String EXTRA_OUTPUT_URI = EXTRA_PREFIX + ".OutputUri"; public static final String EXTRA_OUTPUT_CROP_ASPECT_RATIO = EXTRA_PREFIX + ".CropAspectRatio"; public static final String EXTRA_OUTPUT_IMAGE_WIDTH = EXTRA_PREFIX + ".ImageWidth"; public static final String EXTRA_OUTPUT_IMAGE_HEIGHT = EXTRA_PREFIX + ".ImageHeight"; public static final String EXTRA_OUTPUT_OFFSET_X = EXTRA_PREFIX + ".OffsetX"; public static final String EXTRA_OUTPUT_OFFSET_Y = EXTRA_PREFIX + ".OffsetY"; public static final String EXTRA_ERROR = EXTRA_PREFIX + ".Error"; public static final String EXTRA_ASPECT_RATIO_X = EXTRA_PREFIX + ".AspectRatioX"; public static final String EXTRA_ASPECT_RATIO_Y = EXTRA_PREFIX + ".AspectRatioY"; public static final String EXTRA_MAX_SIZE_X = EXTRA_PREFIX + ".MaxSizeX"; public static final String EXTRA_MAX_SIZE_Y = EXTRA_PREFIX + ".MaxSizeY"; public static final String EXTRA_WINDOW_EXIT_ANIMATION = EXTRA_PREFIX + ".WindowAnimation"; public static final String EXTRA_NAV_BAR_COLOR = EXTRA_PREFIX + ".navBarColor"; private Intent mCropIntent; private Bundle mCropOptionsBundle; /** * This method creates new Intent builder and sets both source and destination image URIs. * * @param source Uri for image to crop * @param destination Uri for saving the cropped image */ public static UCrop of(@NonNull Uri source, @NonNull Uri destination) { return new UCrop(source, destination); } private UCrop(@NonNull Uri source, @NonNull Uri destination) { mCropIntent = new Intent(); mCropOptionsBundle = new Bundle(); mCropOptionsBundle.putParcelable(EXTRA_INPUT_URI, source); mCropOptionsBundle.putParcelable(EXTRA_OUTPUT_URI, destination); } /** * Set an aspect ratio for crop bounds. * User won't see the menu with other ratios options. * * @param x aspect ratio X * @param y aspect ratio Y */ public UCrop withAspectRatio(float x, float y) { mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_X, x); mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_Y, y); return this; } /** * Set an aspect ratio for crop bounds that is evaluated from source image width and height. * User won't see the menu with other ratios options. */ public UCrop useSourceImageAspectRatio() { mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_X, 0); mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_Y, 0); return this; } /** * Set maximum size for result cropped image. * * @param width max cropped image width * @param height max cropped image height */ public UCrop withMaxResultSize(@IntRange(from = 100) int width, @IntRange(from = 100) int height) { mCropOptionsBundle.putInt(EXTRA_MAX_SIZE_X, width); mCropOptionsBundle.putInt(EXTRA_MAX_SIZE_Y, height); return this; } public UCrop withOptions(@NonNull Options options) { mCropOptionsBundle.putAll(options.getOptionBundle()); return this; } /** * Send the crop Intent from animation an Activity * * @param activity Activity to receive result */ public void startAnimation(@NonNull Activity activity, @AnimRes int activityCropEnterAnimation) { if (activityCropEnterAnimation != 0) { start(activity, REQUEST_CROP, activityCropEnterAnimation); } else { start(activity, REQUEST_CROP); } } /** * Send the crop Intent from an Activity with a custom request code or animation * * @param activity Activity to receive result * @param requestCode requestCode for result */ public void start(@NonNull Activity activity, int requestCode, @AnimRes int activityCropEnterAnimation) { activity.startActivityForResult(getIntent(activity), requestCode); activity.overridePendingTransition(activityCropEnterAnimation, R.anim.ucrop_anim_fade_in); } /** * Send the crop Intent from an Activity * * @param activity Activity to receive result */ public void start(@NonNull Activity activity) { start(activity, REQUEST_CROP); } /** * Send the crop Intent from an Activity with a custom request code * * @param activity Activity to receive result * @param requestCode requestCode for result */ public void start(@NonNull Activity activity, int requestCode) { activity.startActivityForResult(getIntent(activity), requestCode); } /** * Send the crop Intent from a Fragment * * @param fragment Fragment to receive result */ public void start(@NonNull Context context, @NonNull Fragment fragment) { start(context, fragment, REQUEST_CROP); } /** * Send the crop Intent with a custom request code * * @param fragment Fragment to receive result * @param requestCode requestCode for result */ public void start(@NonNull Context context, @NonNull Fragment fragment, int requestCode) { fragment.startActivityForResult(getIntent(context), requestCode); } /** * Get Intent to start {@link UCropActivity} * * @return Intent for {@link UCropActivity} */ public Intent getIntent(@NonNull Context context) { mCropIntent.setClass(context, UCropActivity.class); mCropIntent.putExtras(mCropOptionsBundle); return mCropIntent; } /** * Retrieve cropped image Uri from the result Intent * * @param intent crop result intent */ @Nullable public static Uri getOutput(@NonNull Intent intent) { return intent.getParcelableExtra(EXTRA_OUTPUT_URI); } /** * Retrieve the width of the cropped image * * @param intent crop result intent */ public static int getOutputImageWidth(@NonNull Intent intent) { return intent.getIntExtra(EXTRA_OUTPUT_IMAGE_WIDTH, -1); } /** * Retrieve the height of the cropped image * * @param intent crop result intent */ public static int getOutputImageHeight(@NonNull Intent intent) { return intent.getIntExtra(EXTRA_OUTPUT_IMAGE_HEIGHT, -1); } /** * Retrieve cropped image aspect ratio from the result Intent * * @param intent crop result intent * @return aspect ratio as a floating point value (x:y) - so it will be 1 for 1:1 or 4/3 for 4:3 */ public static float getOutputCropAspectRatio(@NonNull Intent intent) { return intent.getFloatExtra(EXTRA_OUTPUT_CROP_ASPECT_RATIO, 1); } /** * Method retrieves error from the result intent. * * @param result crop result Intent * @return Throwable that could happen while image processing */ @Nullable public static Throwable getError(@NonNull Intent result) { return (Throwable) result.getSerializableExtra(EXTRA_ERROR); } /** * Class that helps to setup advanced configs that are not commonly used. * Use it with method {@link #withOptions(Options)} */ public static class Options { public static final String EXTRA_COMPRESSION_FORMAT_NAME = EXTRA_PREFIX + ".CompressionFormatName"; public static final String EXTRA_COMPRESSION_QUALITY = EXTRA_PREFIX + ".CompressionQuality"; public static final String EXTRA_ALLOWED_GESTURES = EXTRA_PREFIX + ".AllowedGestures"; public static final String EXTRA_MAX_BITMAP_SIZE = EXTRA_PREFIX + ".MaxBitmapSize"; public static final String EXTRA_MAX_SCALE_MULTIPLIER = EXTRA_PREFIX + ".MaxScaleMultiplier"; public static final String EXTRA_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION = EXTRA_PREFIX + ".ImageToCropBoundsAnimDuration"; public static final String EXTRA_DIMMED_LAYER_COLOR = EXTRA_PREFIX + ".DimmedLayerColor"; public static final String EXTRA_CIRCLE_DIMMED_LAYER = EXTRA_PREFIX + ".CircleDimmedLayer"; public static final String EXTRA_SHOW_CROP_FRAME = EXTRA_PREFIX + ".ShowCropFrame"; public static final String EXTRA_CROP_FRAME_COLOR = EXTRA_PREFIX + ".CropFrameColor"; public static final String EXTRA_CROP_FRAME_STROKE_WIDTH = EXTRA_PREFIX + ".CropFrameStrokeWidth"; public static final String EXTRA_SHOW_CROP_GRID = EXTRA_PREFIX + ".ShowCropGrid"; public static final String EXTRA_CROP_GRID_ROW_COUNT = EXTRA_PREFIX + ".CropGridRowCount"; public static final String EXTRA_CROP_GRID_COLUMN_COUNT = EXTRA_PREFIX + ".CropGridColumnCount"; public static final String EXTRA_CROP_GRID_COLOR = EXTRA_PREFIX + ".CropGridColor"; public static final String EXTRA_CROP_GRID_STROKE_WIDTH = EXTRA_PREFIX + ".CropGridStrokeWidth"; public static final String EXTRA_TOOL_BAR_COLOR = EXTRA_PREFIX + ".ToolbarColor"; public static final String EXTRA_STATUS_BAR_COLOR = EXTRA_PREFIX + ".StatusBarColor"; public static final String EXTRA_UCROP_COLOR_WIDGET_ACTIVE = EXTRA_PREFIX + ".UcropColorWidgetActive"; public static final String EXTRA_UCROP_WIDGET_COLOR_TOOLBAR = EXTRA_PREFIX + ".UcropToolbarWidgetColor"; public static final String EXTRA_UCROP_TITLE_TEXT_TOOLBAR = EXTRA_PREFIX + ".UcropToolbarTitleText"; public static final String EXTRA_UCROP_WIDGET_CANCEL_DRAWABLE = EXTRA_PREFIX + ".UcropToolbarCancelDrawable"; public static final String EXTRA_UCROP_WIDGET_CROP_DRAWABLE = EXTRA_PREFIX + ".UcropToolbarCropDrawable"; public static final String EXTRA_UCROP_WIDGET_CROP_OPEN_WHITE_STATUSBAR = EXTRA_PREFIX + ".openWhiteStatusBar"; public static final String EXTRA_UCROP_LOGO_COLOR = EXTRA_PREFIX + ".UcropLogoColor"; public static final String EXTRA_FREE_STYLE_CROP = EXTRA_PREFIX + ".FreeStyleCrop"; public static final String EXTRA_CUT_CROP = EXTRA_PREFIX + ".cuts"; public static final String EXTRA_FREE_STATUS_FONT = EXTRA_PREFIX + ".StatusFont"; public static final String EXTRA_ASPECT_RATIO_SELECTED_BY_DEFAULT = EXTRA_PREFIX + ".AspectRatioSelectedByDefault"; public static final String EXTRA_ASPECT_RATIO_OPTIONS = EXTRA_PREFIX + ".AspectRatioOptions"; public static final String EXTRA_UCROP_ROOT_VIEW_BACKGROUND_COLOR = EXTRA_PREFIX + ".UcropRootViewBackgroundColor"; public static final String EXTRA_DRAG_CROP_FRAME = EXTRA_PREFIX + ".DragCropFrame"; private final Bundle mOptionBundle; public Options() { mOptionBundle = new Bundle(); } @NonNull public Bundle getOptionBundle() { return mOptionBundle; } /** * Set one of {@link Bitmap.CompressFormat} that will be used to save resulting Bitmap. */ public Options setCompressionFormat(@NonNull Bitmap.CompressFormat format) { mOptionBundle.putString(EXTRA_COMPRESSION_FORMAT_NAME, format.name()); return this; } /** * Set compression quality [0-100] that will be used to save resulting Bitmap. */ public Options setCompressionQuality(@IntRange(from = 0) int compressQuality) { mOptionBundle.putInt(EXTRA_COMPRESSION_QUALITY, compressQuality); return this; } /** * Choose what set of gestures will be enabled on each tab - if any. */ public Options setAllowedGestures(@UCropActivity.GestureTypes int tabScale, @UCropActivity.GestureTypes int tabRotate, @UCropActivity.GestureTypes int tabAspectRatio) { mOptionBundle.putIntArray(EXTRA_ALLOWED_GESTURES, new int[]{tabScale, tabRotate, tabAspectRatio}); return this; } /** * This method sets multiplier that is used to calculate max image scale from min image scale. * * @param maxScaleMultiplier - (minScale * maxScaleMultiplier) = maxScale */ public Options setMaxScaleMultiplier(@FloatRange(from = 1.0, fromInclusive = false) float maxScaleMultiplier) { mOptionBundle.putFloat(EXTRA_MAX_SCALE_MULTIPLIER, maxScaleMultiplier); return this; } /** * This method sets animation duration for image to wrap the crop bounds * * @param durationMillis - duration in milliseconds */ public Options setImageToCropBoundsAnimDuration(@IntRange(from = 100) int durationMillis) { mOptionBundle.putInt(EXTRA_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION, durationMillis); return this; } /** * Setter for max size for both width and height of bitmap that will be decoded from an input Uri and used in the view. * * @param maxBitmapSize - size in pixels */ public Options setMaxBitmapSize(@IntRange(from = 100) int maxBitmapSize) { mOptionBundle.putInt(EXTRA_MAX_BITMAP_SIZE, maxBitmapSize); return this; } /** * @param color - desired color of dimmed area around the crop bounds */ public Options setDimmedLayerColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_DIMMED_LAYER_COLOR, color); return this; } /** * @param isCircle - set it to true if you want dimmed layer to have an circle inside */ public Options setCircleDimmedLayer(boolean isCircle) { mOptionBundle.putBoolean(EXTRA_CIRCLE_DIMMED_LAYER, isCircle); return this; } /** * @param show - set to true if you want to see a crop frame rectangle on top of an image */ public Options setShowCropFrame(boolean show) { mOptionBundle.putBoolean(EXTRA_SHOW_CROP_FRAME, show); return this; } /** * @param color - desired color of crop frame */ public Options setCropFrameColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_CROP_FRAME_COLOR, color); return this; } /** * @param width - desired width of crop frame line in pixels */ public Options setCropFrameStrokeWidth(@IntRange(from = 0) int width) { mOptionBundle.putInt(EXTRA_CROP_FRAME_STROKE_WIDTH, width); return this; } /** * @param show - set to true if you want to see a crop grid/guidelines on top of an image */ public Options setShowCropGrid(boolean show) { mOptionBundle.putBoolean(EXTRA_SHOW_CROP_GRID, show); return this; } /** * @param isDragFrame - 是否可拖动裁剪框 */ public Options setDragFrameEnabled(boolean isDragFrame) { mOptionBundle.putBoolean(EXTRA_DRAG_CROP_FRAME, isDragFrame); return this; } /** * @param count - crop grid rows count. */ public Options setCropGridRowCount(@IntRange(from = 0) int count) { mOptionBundle.putInt(EXTRA_CROP_GRID_ROW_COUNT, count); return this; } /** * @param count - crop grid columns count. */ public Options setCropGridColumnCount(@IntRange(from = 0) int count) { mOptionBundle.putInt(EXTRA_CROP_GRID_COLUMN_COUNT, count); return this; } /** * @param color - desired color of crop grid/guidelines */ public Options setCropGridColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_CROP_GRID_COLOR, color); return this; } /** * @param width - desired width of crop grid lines in pixels */ public Options setCropGridStrokeWidth(@IntRange(from = 0) int width) { mOptionBundle.putInt(EXTRA_CROP_GRID_STROKE_WIDTH, width); return this; } /** * @param color - desired resolved color of the toolbar */ public Options setToolbarColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_TOOL_BAR_COLOR, color); return this; } /** * @param color - desired resolved color of the statusbar */ public Options setStatusBarColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_STATUS_BAR_COLOR, color); return this; } /** * @param color - desired resolved color of the active and selected widget (default is orange) and progress wheel middle line */ public Options setActiveWidgetColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_COLOR_WIDGET_ACTIVE, color); return this; } /** * @param color - desired resolved color of Toolbar text and buttons (default is darker orange) */ public Options setToolbarWidgetColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_WIDGET_COLOR_TOOLBAR, color); return this; } /** * @param openWhiteStatusBar - Change the status bar font color */ public Options isOpenWhiteStatusBar(boolean openWhiteStatusBar) { mOptionBundle.putBoolean(EXTRA_UCROP_WIDGET_CROP_OPEN_WHITE_STATUSBAR, openWhiteStatusBar); return this; } /** * @param text - desired text for Toolbar title */ public Options setToolbarTitle(@Nullable String text) { mOptionBundle.putString(EXTRA_UCROP_TITLE_TEXT_TOOLBAR, text); return this; } /** * @param drawable - desired drawable for the Toolbar left cancel icon */ public Options setToolbarCancelDrawable(@DrawableRes int drawable) { mOptionBundle.putInt(EXTRA_UCROP_WIDGET_CANCEL_DRAWABLE, drawable); return this; } /** * @param drawable - desired drawable for the Toolbar right crop icon */ public Options setToolbarCropDrawable(@DrawableRes int drawable) { mOptionBundle.putInt(EXTRA_UCROP_WIDGET_CROP_DRAWABLE, drawable); return this; } /** * @param color - desired resolved color of logo fill (default is darker grey) */ public Options setLogoColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_LOGO_COLOR, color); return this; } /** * @param -set cuts path */ public Options setCutListData(ArrayList list) { mOptionBundle.putStringArrayList(EXTRA_CUT_CROP, list); return this; } /** * @param enabled - set to true to let user resize crop bounds (disabled by default) */ public Options setFreeStyleCropEnabled(boolean enabled) { mOptionBundle.putBoolean(EXTRA_FREE_STYLE_CROP, enabled); return this; } /** * @param statusFont - Set status bar black */ public Options setStatusFont(boolean statusFont) { mOptionBundle.putBoolean(EXTRA_FREE_STATUS_FONT, statusFont); return this; } /** * Pass an ordered list of desired aspect ratios that should be available for a user. * * @param selectedByDefault - index of aspect ratio option that is selected by default (starts with 0). * @param aspectRatio - list of aspect ratio options that are available to user */ public Options setAspectRatioOptions(int selectedByDefault, AspectRatio... aspectRatio) { if (selectedByDefault > aspectRatio.length) { throw new IllegalArgumentException(String.format(Locale.US, "Index [selectedByDefault = %d] cannot be higher than aspect ratio options count [count = %d].", selectedByDefault, aspectRatio.length)); } mOptionBundle.putInt(EXTRA_ASPECT_RATIO_SELECTED_BY_DEFAULT, selectedByDefault); mOptionBundle.putParcelableArrayList(EXTRA_ASPECT_RATIO_OPTIONS, new ArrayList(Arrays.asList(aspectRatio))); return this; } /** * @param color - desired background color that should be applied to the root view */ public Options setRootViewBackgroundColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_ROOT_VIEW_BACKGROUND_COLOR, color); return this; } /** * Set an aspect ratio for crop bounds. * User won't see the menu with other ratios options. * * @param x aspect ratio X * @param y aspect ratio Y */ public Options withAspectRatio(float x, float y) { mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_X, x); mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_Y, y); return this; } /** * Set an aspect ratio for crop bounds that is evaluated from source image width and height. * User won't see the menu with other ratios options. */ public Options useSourceImageAspectRatio() { mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_X, 0); mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_Y, 0); return this; } /** * Set maximum size for result cropped image. * * @param width max cropped image width * @param height max cropped image height */ public Options withMaxResultSize(int width, int height) { mOptionBundle.putInt(EXTRA_MAX_SIZE_X, width); mOptionBundle.putInt(EXTRA_MAX_SIZE_Y, height); return this; } /** * @param activityCropExitAnimation activity exit animation */ public Options setCropExitAnimation(@AnimRes int activityCropExitAnimation) { mOptionBundle.putInt(EXTRA_WINDOW_EXIT_ANIMATION, activityCropExitAnimation); return this; } /** * @param navBarColor set NavBar Color */ public Options setNavBarColor(@ColorInt int navBarColor) { mOptionBundle.putInt(EXTRA_NAV_BAR_COLOR, navBarColor); return this; } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/UCropActivity.java ================================================ package com.matisse.ucrop; import android.content.Intent; import android.graphics.Bitmap; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.view.animation.AccelerateInterpolator; import android.widget.LinearLayout; import android.widget.RelativeLayout; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import com.matisse.R; import com.matisse.ucrop.callback.BitmapCropCallback; import com.matisse.ucrop.model.AspectRatio; import com.matisse.ucrop.util.FileUtils; import com.matisse.ucrop.view.CropImageView; import com.matisse.ucrop.view.GestureCropImageView; import com.matisse.ucrop.view.OverlayView; import com.matisse.ucrop.view.TransformImageView; import com.matisse.ucrop.view.UCropView; import com.matisse.ui.activity.BaseActivity; import com.matisse.utils.UIUtils; import java.io.FileInputStream; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; /** * Created by Oleksii Shliama (https://github.com/shliama). */ @SuppressWarnings("ConstantConditions") public class UCropActivity extends BaseActivity implements View.OnClickListener { public static final int DEFAULT_COMPRESS_QUALITY = 100; public static final Bitmap.CompressFormat DEFAULT_COMPRESS_FORMAT = Bitmap.CompressFormat.PNG; public static final int NONE = 0; public static final int SCALE = 1; public static final int ROTATE = 2; public static final int ALL = 3; @IntDef({NONE, SCALE, ROTATE, ALL}) @Retention(RetentionPolicy.SOURCE) public @interface GestureTypes { } private UCropView mUCropView; private GestureCropImageView mGestureCropImageView; private OverlayView mOverlayView; private View mBlockingView; private Bitmap.CompressFormat mCompressFormat = DEFAULT_COMPRESS_FORMAT; private int mCompressQuality = DEFAULT_COMPRESS_QUALITY; private boolean mIsCircleCrop = false; @Override public int getResourceLayoutId() { return R.layout.ucrop_activity_photobox; } @Override public void configActivity() { super.configActivity(); getSpec().getStatusBarFuture().invoke(this, findViewById(R.id.toolbar)); } @Override public void setViewData() { final Intent intent = getIntent(); setupViews(); setImageData(intent); addBlockingView(); } @Override public void initListener() { UIUtils.setOnClickListener(this , findViewById(R.id.button_complete), findViewById(R.id.button_back)); } @Override public void onClick(View view) { int id = view.getId(); if (id == R.id.button_complete) { cropAndSaveImage(); } else if (id == R.id.button_back) { onBackPressed(); } } @Override public void onBackPressed() { super.onBackPressed(); // exitAnimation(); } @Override protected void onStop() { super.onStop(); if (mGestureCropImageView != null) { mGestureCropImageView.cancelAllAnimations(); } } /** * This method extracts all data from the incoming intent and setups views properly. */ private void setImageData(@NonNull Intent intent) { Uri inputUri = intent.getParcelableExtra(UCrop.EXTRA_INPUT_URI); Uri outputUri = intent.getParcelableExtra(UCrop.EXTRA_OUTPUT_URI); processOptions(intent); if (inputUri != null && outputUri != null) { try { ParcelFileDescriptor parcelFileDescriptor = getContentResolver().openFileDescriptor(inputUri, "r"); FileInputStream inputStream = new FileInputStream(parcelFileDescriptor.getFileDescriptor()); String suffix = FileUtils.extSuffix(inputStream); boolean isGif = FileUtils.isGifForSuffix(suffix); mGestureCropImageView.setRotateEnabled(!isGif); mGestureCropImageView.setScaleEnabled(!isGif); mGestureCropImageView.setImageUri(inputUri, outputUri); } catch (Exception e) { setResultError(e); closeActivity(); } } else { setResultError(new NullPointerException(getString(R.string.ucrop_error_input_data_is_absent))); closeActivity(); } } /** * This method extracts {@link UCrop.Options #optionsBundle} from incoming intent * and setups Activity, {@link OverlayView} and {@link CropImageView} properly. */ private void processOptions(@NonNull Intent intent) { // Bitmap compression options String compressionFormatName = intent.getStringExtra(UCrop.Options.EXTRA_COMPRESSION_FORMAT_NAME); Bitmap.CompressFormat compressFormat = null; if (!TextUtils.isEmpty(compressionFormatName)) { compressFormat = Bitmap.CompressFormat.valueOf(compressionFormatName); } mCompressFormat = (compressFormat == null) ? DEFAULT_COMPRESS_FORMAT : compressFormat; mCompressQuality = intent.getIntExtra(UCrop.Options.EXTRA_COMPRESSION_QUALITY, UCropActivity.DEFAULT_COMPRESS_QUALITY); // Crop image view options mGestureCropImageView.setMaxBitmapSize(intent.getIntExtra(UCrop.Options.EXTRA_MAX_BITMAP_SIZE, CropImageView.DEFAULT_MAX_BITMAP_SIZE)); mGestureCropImageView.setMaxScaleMultiplier(intent.getFloatExtra(UCrop.Options.EXTRA_MAX_SCALE_MULTIPLIER, CropImageView.DEFAULT_MAX_SCALE_MULTIPLIER)); mGestureCropImageView.setImageToWrapCropBoundsAnimDuration(intent.getIntExtra(UCrop.Options.EXTRA_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION, CropImageView.DEFAULT_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION)); // Overlay view options mOverlayView.setFreestyleCropEnabled(intent.getBooleanExtra(UCrop.Options.EXTRA_FREE_STYLE_CROP, false)); mOverlayView.setDragFrame(true); mOverlayView.setDimmedColor(intent.getIntExtra(UCrop.Options.EXTRA_DIMMED_LAYER_COLOR, getResources().getColor(R.color.ucrop_color_default_dimmed))); mIsCircleCrop = intent.getBooleanExtra(UCrop.Options.EXTRA_CIRCLE_DIMMED_LAYER, OverlayView.DEFAULT_CIRCLE_DIMMED_LAYER); mOverlayView.setCircleDimmedLayer(mIsCircleCrop); mOverlayView.setShowCropFrame(intent.getBooleanExtra(UCrop.Options.EXTRA_SHOW_CROP_FRAME, OverlayView.DEFAULT_SHOW_CROP_FRAME)); mOverlayView.setCropFrameColor(intent.getIntExtra(UCrop.Options.EXTRA_CROP_FRAME_COLOR, getResources().getColor(R.color.ucrop_color_default_crop_frame))); mOverlayView.setCropFrameStrokeWidth(intent.getIntExtra(UCrop.Options.EXTRA_CROP_FRAME_STROKE_WIDTH, getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_frame_stoke_width))); mOverlayView.setShowCropGrid(intent.getBooleanExtra(UCrop.Options.EXTRA_SHOW_CROP_GRID, OverlayView.DEFAULT_SHOW_CROP_GRID)); mOverlayView.setCropGridRowCount(intent.getIntExtra(UCrop.Options.EXTRA_CROP_GRID_ROW_COUNT, OverlayView.DEFAULT_CROP_GRID_ROW_COUNT)); mOverlayView.setCropGridColumnCount(intent.getIntExtra(UCrop.Options.EXTRA_CROP_GRID_COLUMN_COUNT, OverlayView.DEFAULT_CROP_GRID_COLUMN_COUNT)); mOverlayView.setCropGridColor(intent.getIntExtra(UCrop.Options.EXTRA_CROP_GRID_COLOR, getResources().getColor(R.color.ucrop_color_default_crop_grid))); mOverlayView.setCropGridStrokeWidth(intent.getIntExtra(UCrop.Options.EXTRA_CROP_GRID_STROKE_WIDTH, getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_grid_stoke_width))); // Aspect ratio options float aspectRatioX = intent.getFloatExtra(UCrop.EXTRA_ASPECT_RATIO_X, 0); float aspectRatioY = intent.getFloatExtra(UCrop.EXTRA_ASPECT_RATIO_Y, 0); int aspectRationSelectedByDefault = intent.getIntExtra(UCrop.Options.EXTRA_ASPECT_RATIO_SELECTED_BY_DEFAULT, 0); ArrayList aspectRatioList = intent.getParcelableArrayListExtra(UCrop.Options.EXTRA_ASPECT_RATIO_OPTIONS); if (aspectRatioX > 0 && aspectRatioY > 0) { mGestureCropImageView.setTargetAspectRatio(aspectRatioX / aspectRatioY); } else if (aspectRatioList != null && aspectRationSelectedByDefault < aspectRatioList.size()) { mGestureCropImageView.setTargetAspectRatio(aspectRatioList.get(aspectRationSelectedByDefault).getAspectRatioX() / aspectRatioList.get(aspectRationSelectedByDefault).getAspectRatioY()); } else { mGestureCropImageView.setTargetAspectRatio(CropImageView.SOURCE_IMAGE_ASPECT_RATIO); } // Result bitmap max size options int maxSizeX = intent.getIntExtra(UCrop.EXTRA_MAX_SIZE_X, 0); int maxSizeY = intent.getIntExtra(UCrop.EXTRA_MAX_SIZE_Y, 0); if (maxSizeX > 0 && maxSizeY > 0) { mGestureCropImageView.setMaxResultImageSizeX(maxSizeX); mGestureCropImageView.setMaxResultImageSizeY(maxSizeY); } } private void setupViews() { initiateRootViews(); } private void initiateRootViews() { mUCropView = findViewById(R.id.ucrop); mGestureCropImageView = mUCropView.getCropImageView(); mOverlayView = mUCropView.getOverlayView(); mGestureCropImageView.setTransformImageListener(mImageListener); } private TransformImageView.TransformImageListener mImageListener = new TransformImageView.TransformImageListener() { @Override public void onRotate(float currentAngle) { } @Override public void onScale(float currentScale) { } @Override public void onLoadComplete() { mUCropView.animate().alpha(1).setDuration(300).setInterpolator(new AccelerateInterpolator()); mBlockingView.setClickable(false); supportInvalidateOptionsMenu(); } @Override public void onLoadFailure(@NonNull Exception e) { setResultError(e); closeActivity(); } }; /** * Adds view that covers everything below the Toolbar. * When it's clickable - user won't be able to click/touch anything below the Toolbar. * Need to block user input while loading and cropping an image. */ private void addBlockingView() { if (mBlockingView == null) { mBlockingView = new View(this); RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); lp.addRule(RelativeLayout.BELOW, R.id.toolbar); mBlockingView.setLayoutParams(lp); mBlockingView.setClickable(true); } ((LinearLayout) findViewById(R.id.ucrop_photobox)).addView(mBlockingView); } protected void cropAndSaveImage() { mBlockingView.setClickable(true); supportInvalidateOptionsMenu(); mGestureCropImageView.cropAndSaveImage(mCompressFormat, mCompressQuality, mIsCircleCrop, new BitmapCropCallback() { @Override public void onBitmapCropped(@NonNull Uri resultUri, int offsetX, int offsetY, int imageWidth, int imageHeight) { setResultUri(resultUri, mGestureCropImageView.getTargetAspectRatio(), offsetX, offsetY, imageWidth, imageHeight); } @Override public void onCropFailure(@NonNull Throwable t) { setResultError(t); closeActivity(); } }); } protected void setResultUri(Uri uri, float resultAspectRatio, int offsetX, int offsetY, int imageWidth, int imageHeight) { setResult(RESULT_OK, new Intent() .putExtra(UCrop.EXTRA_OUTPUT_URI, uri) .putExtra(UCrop.EXTRA_OUTPUT_CROP_ASPECT_RATIO, resultAspectRatio) .putExtra(UCrop.EXTRA_OUTPUT_IMAGE_WIDTH, imageWidth) .putExtra(UCrop.EXTRA_OUTPUT_IMAGE_HEIGHT, imageHeight) .putExtra(UCrop.EXTRA_OUTPUT_OFFSET_X, offsetX) .putExtra(UCrop.EXTRA_OUTPUT_OFFSET_Y, offsetY) ); closeActivity(); } protected void setResultError(Throwable throwable) { setResult(UCrop.RESULT_ERROR, new Intent().putExtra(UCrop.EXTRA_ERROR, throwable)); } /** * exit activity */ protected void closeActivity() { finish(); // exitAnimation(); } protected void exitAnimation() { int exitAnimation = getIntent().getIntExtra(UCrop.EXTRA_WINDOW_EXIT_ANIMATION, 0); overridePendingTransition(R.anim.ucrop_anim_fade_in, exitAnimation != 0 ? exitAnimation : R.anim.ucrop_close); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/UCropMulti.java ================================================ package com.matisse.ucrop; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import androidx.annotation.AnimRes; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.FloatRange; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.matisse.BuildConfig; import com.matisse.R; import com.matisse.ucrop.model.AspectRatio; import com.matisse.ucrop.model.CutInfo; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; /** * Created by Oleksii Shliama (https://github.com/shliama). *

* Builder class to ease Intent setup. */ public class UCropMulti { public static final int REQUEST_MULTI_CROP = 609; public static final int RESULT_ERROR = 96; private static final String EXTRA_PREFIX = BuildConfig.APPLICATION_ID; public static final String EXTRA_INPUT_URI = EXTRA_PREFIX + ".InputUri"; public static final String EXTRA_OUTPUT_URI = EXTRA_PREFIX + ".OutputUri"; public static final String EXTRA_OUTPUT_URI_LIST = EXTRA_PREFIX + ".OutputUriList"; public static final String EXTRA_OUTPUT_CROP_ASPECT_RATIO = EXTRA_PREFIX + ".CropAspectRatio"; public static final String EXTRA_OUTPUT_IMAGE_WIDTH = EXTRA_PREFIX + ".ImageWidth"; public static final String EXTRA_OUTPUT_IMAGE_HEIGHT = EXTRA_PREFIX + ".ImageHeight"; public static final String EXTRA_OUTPUT_OFFSET_X = EXTRA_PREFIX + ".OffsetX"; public static final String EXTRA_OUTPUT_OFFSET_Y = EXTRA_PREFIX + ".OffsetY"; public static final String EXTRA_ERROR = EXTRA_PREFIX + ".Error"; public static final String EXTRA_WINDOW_EXIT_ANIMATION = EXTRA_PREFIX + ".WindowAnimation"; public static final String EXTRA_NAV_BAR_COLOR = EXTRA_PREFIX + ".navBarColor"; public static final String EXTRA_ASPECT_RATIO_X = EXTRA_PREFIX + ".AspectRatioX"; public static final String EXTRA_ASPECT_RATIO_Y = EXTRA_PREFIX + ".AspectRatioY"; public static final String EXTRA_MAX_SIZE_X = EXTRA_PREFIX + ".MaxSizeX"; public static final String EXTRA_MAX_SIZE_Y = EXTRA_PREFIX + ".MaxSizeY"; private Intent mCropIntent; private Bundle mCropOptionsBundle; /** * This method creates new Intent builder and sets both source and destination image URIs. * * @param source Uri for image to crop * @param destination Uri for saving the cropped image */ public static UCropMulti of(@NonNull Uri source, @NonNull Uri destination) { return new UCropMulti(source, destination); } private UCropMulti(@NonNull Uri source, @NonNull Uri destination) { mCropIntent = new Intent(); mCropOptionsBundle = new Bundle(); mCropOptionsBundle.putParcelable(EXTRA_INPUT_URI, source); mCropOptionsBundle.putParcelable(EXTRA_OUTPUT_URI, destination); } /** * Set an aspect ratio for crop bounds. * User won't see the menu with other ratios options. * * @param x aspect ratio X * @param y aspect ratio Y */ public UCropMulti withAspectRatio(float x, float y) { mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_X, x); mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_Y, y); return this; } /** * Set an aspect ratio for crop bounds that is evaluated from source image width and height. * User won't see the menu with other ratios options. */ public UCropMulti useSourceImageAspectRatio() { mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_X, 0); mCropOptionsBundle.putFloat(EXTRA_ASPECT_RATIO_Y, 0); return this; } /** * Set maximum size for result cropped image. * * @param width max cropped image width * @param height max cropped image height */ public UCropMulti withMaxResultSize(@IntRange(from = 100) int width, @IntRange(from = 100) int height) { mCropOptionsBundle.putInt(EXTRA_MAX_SIZE_X, width); mCropOptionsBundle.putInt(EXTRA_MAX_SIZE_Y, height); return this; } public UCropMulti withOptions(@NonNull Options options) { mCropOptionsBundle.putAll(options.getOptionBundle()); return this; } /** * Send the crop Intent from animation an Activity * * @param activity Activity to receive result */ public void startAnimation(@NonNull Activity activity, @AnimRes int activityCropEnterAnimation) { if (activityCropEnterAnimation != 0) { start(activity, REQUEST_MULTI_CROP, activityCropEnterAnimation); } else { start(activity, REQUEST_MULTI_CROP); } } /** * Send the crop Intent from an Activity with a custom request code or animation * * @param activity Activity to receive result * @param requestCode requestCode for result */ public void start(@NonNull Activity activity, int requestCode, @AnimRes int activityCropEnterAnimation) { activity.startActivityForResult(getIntent(activity), requestCode); activity.overridePendingTransition(activityCropEnterAnimation, R.anim.ucrop_anim_fade_in); } /** * Send the crop Intent from an Activity * * @param activity Activity to receive result */ public void start(@NonNull Activity activity) { start(activity, REQUEST_MULTI_CROP); } /** * Send the crop Intent from an Activity with a custom request code * * @param activity Activity to receive result * @param requestCode requestCode for result */ public void start(@NonNull Activity activity, int requestCode) { activity.startActivityForResult(getIntent(activity), requestCode); } /** * Send the crop Intent from a Fragment * * @param fragment Fragment to receive result */ public void start(@NonNull Context context, @NonNull Fragment fragment) { start(context, fragment, REQUEST_MULTI_CROP); } /** * Send the crop Intent with a custom request code * * @param fragment Fragment to receive result * @param requestCode requestCode for result */ public void start(@NonNull Context context, @NonNull Fragment fragment, int requestCode) { fragment.startActivityForResult(getIntent(context), requestCode); } /** * Get Intent to start {@link PictureMultiCuttingActivity} * * @return Intent for {@link PictureMultiCuttingActivity} */ public Intent getIntent(@NonNull Context context) { mCropIntent.setClass(context, PictureMultiCuttingActivity.class); mCropIntent.putExtras(mCropOptionsBundle); return mCropIntent; } /** * Retrieve cropped image Cuts from the result Intent * * @param intent crop result intent */ @Nullable public static List getOutput(@NonNull Intent intent) { return (List) intent.getSerializableExtra(EXTRA_OUTPUT_URI_LIST); } /** * Retrieve the width of the cropped image * * @param intent crop result intent */ public static int getOutputImageWidth(@NonNull Intent intent) { return intent.getIntExtra(EXTRA_OUTPUT_IMAGE_WIDTH, -1); } /** * Retrieve the height of the cropped image * * @param intent crop result intent */ public static int getOutputImageHeight(@NonNull Intent intent) { return intent.getIntExtra(EXTRA_OUTPUT_IMAGE_HEIGHT, -1); } /** * Retrieve cropped image aspect ratio from the result Intent * * @param intent crop result intent * @return aspect ratio as a floating point value (x:y) - so it will be 1 for 1:1 or 4/3 for 4:3 */ public static float getOutputCropAspectRatio(@NonNull Intent intent) { return intent.getFloatExtra(EXTRA_OUTPUT_CROP_ASPECT_RATIO, 1); } /** * Method retrieves error from the result intent. * * @param result crop result Intent * @return Throwable that could happen while image processing */ @Nullable public static Throwable getError(@NonNull Intent result) { return (Throwable) result.getSerializableExtra(EXTRA_ERROR); } /** * Class that helps to setup advanced configs that are not commonly used. * Use it with method {@link #withOptions(Options)} */ public static class Options { public static final String EXTRA_COMPRESSION_FORMAT_NAME = EXTRA_PREFIX + ".CompressionFormatName"; public static final String EXTRA_COMPRESSION_QUALITY = EXTRA_PREFIX + ".CompressionQuality"; public static final String EXTRA_ALLOWED_GESTURES = EXTRA_PREFIX + ".AllowedGestures"; public static final String EXTRA_MAX_BITMAP_SIZE = EXTRA_PREFIX + ".MaxBitmapSize"; public static final String EXTRA_MAX_SCALE_MULTIPLIER = EXTRA_PREFIX + ".MaxScaleMultiplier"; public static final String EXTRA_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION = EXTRA_PREFIX + ".ImageToCropBoundsAnimDuration"; public static final String EXTRA_DIMMED_LAYER_COLOR = EXTRA_PREFIX + ".DimmedLayerColor"; public static final String EXTRA_CIRCLE_DIMMED_LAYER = EXTRA_PREFIX + ".CircleDimmedLayer"; public static final String EXTRA_SHOW_CROP_FRAME = EXTRA_PREFIX + ".ShowCropFrame"; public static final String EXTRA_CROP_FRAME_COLOR = EXTRA_PREFIX + ".CropFrameColor"; public static final String EXTRA_CROP_FRAME_STROKE_WIDTH = EXTRA_PREFIX + ".CropFrameStrokeWidth"; public static final String EXTRA_SHOW_CROP_GRID = EXTRA_PREFIX + ".ShowCropGrid"; public static final String EXTRA_CROP_GRID_ROW_COUNT = EXTRA_PREFIX + ".CropGridRowCount"; public static final String EXTRA_CROP_GRID_COLUMN_COUNT = EXTRA_PREFIX + ".CropGridColumnCount"; public static final String EXTRA_CROP_GRID_COLOR = EXTRA_PREFIX + ".CropGridColor"; public static final String EXTRA_CROP_GRID_STROKE_WIDTH = EXTRA_PREFIX + ".CropGridStrokeWidth"; public static final String EXTRA_TOOL_BAR_COLOR = EXTRA_PREFIX + ".ToolbarColor"; public static final String EXTRA_STATUS_BAR_COLOR = EXTRA_PREFIX + ".StatusBarColor"; public static final String EXTRA_UCROP_COLOR_WIDGET_ACTIVE = EXTRA_PREFIX + ".UcropColorWidgetActive"; public static final String EXTRA_UCROP_WIDGET_CROP_OPEN_WHITE_STATUSBAR = EXTRA_PREFIX + ".openWhiteStatusBar"; public static final String EXTRA_UCROP_WIDGET_COLOR_TOOLBAR = EXTRA_PREFIX + ".UcropToolbarWidgetColor"; public static final String EXTRA_UCROP_TITLE_TEXT_TOOLBAR = EXTRA_PREFIX + ".UcropToolbarTitleText"; public static final String EXTRA_UCROP_WIDGET_CANCEL_DRAWABLE = EXTRA_PREFIX + ".UcropToolbarCancelDrawable"; public static final String EXTRA_UCROP_WIDGET_CROP_DRAWABLE = EXTRA_PREFIX + ".UcropToolbarCropDrawable"; public static final String EXTRA_UCROP_LOGO_COLOR = EXTRA_PREFIX + ".UcropLogoColor"; public static final String EXTRA_FREE_STYLE_CROP = EXTRA_PREFIX + ".FreeStyleCrop"; public static final String EXTRA_CUT_CROP = EXTRA_PREFIX + ".cuts"; public static final String EXTRA_FREE_STATUS_FONT = EXTRA_PREFIX + ".StatusFont"; public static final String EXTRA_DRAG_CROP_FRAME = EXTRA_PREFIX + ".DragCropFrame"; public static final String EXTRA_ROTATE = EXTRA_PREFIX + ".rotate"; public static final String EXTRA_SCALE = EXTRA_PREFIX + ".scale"; public static final String EXTRA_ASPECT_RATIO_SELECTED_BY_DEFAULT = EXTRA_PREFIX + ".AspectRatioSelectedByDefault"; public static final String EXTRA_ASPECT_RATIO_OPTIONS = EXTRA_PREFIX + ".AspectRatioOptions"; public static final String EXTRA_UCROP_ROOT_VIEW_BACKGROUND_COLOR = EXTRA_PREFIX + ".UcropRootViewBackgroundColor"; private final Bundle mOptionBundle; public Options() { mOptionBundle = new Bundle(); } @NonNull public Bundle getOptionBundle() { return mOptionBundle; } /** * Set one of {@link Bitmap.CompressFormat} that will be used to save resulting Bitmap. */ public Options setCompressionFormat(@NonNull Bitmap.CompressFormat format) { mOptionBundle.putString(EXTRA_COMPRESSION_FORMAT_NAME, format.name()); return this; } /** * Set compression quality [0-100] that will be used to save resulting Bitmap. */ public Options setCompressionQuality(@IntRange(from = 0) int compressQuality) { mOptionBundle.putInt(EXTRA_COMPRESSION_QUALITY, compressQuality); return this; } /** * Choose what set of gestures will be enabled on each tab - if any. */ public Options setAllowedGestures(@PictureMultiCuttingActivity.GestureTypes int tabScale, @PictureMultiCuttingActivity.GestureTypes int tabRotate, @PictureMultiCuttingActivity.GestureTypes int tabAspectRatio) { mOptionBundle.putIntArray(EXTRA_ALLOWED_GESTURES, new int[]{tabScale, tabRotate, tabAspectRatio}); return this; } /** * This method sets multiplier that is used to calculate max image scale from min image scale. * * @param maxScaleMultiplier - (minScale * maxScaleMultiplier) = maxScale */ public Options setMaxScaleMultiplier(@FloatRange(from = 1.0, fromInclusive = false) float maxScaleMultiplier) { mOptionBundle.putFloat(EXTRA_MAX_SCALE_MULTIPLIER, maxScaleMultiplier); return this; } /** * This method sets animation duration for image to wrap the crop bounds * * @param durationMillis - duration in milliseconds */ public Options setImageToCropBoundsAnimDuration(@IntRange(from = 100) int durationMillis) { mOptionBundle.putInt(EXTRA_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION, durationMillis); return this; } /** * Setter for max size for both width and height of bitmap that will be decoded from an input Uri and used in the view. * * @param maxBitmapSize - size in pixels */ public Options setMaxBitmapSize(@IntRange(from = 100) int maxBitmapSize) { mOptionBundle.putInt(EXTRA_MAX_BITMAP_SIZE, maxBitmapSize); return this; } /** * @param color - desired color of dimmed area around the crop bounds */ public Options setDimmedLayerColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_DIMMED_LAYER_COLOR, color); return this; } /** * @param isCircle - set it to true if you want dimmed layer to have an circle inside */ public Options setCircleDimmedLayer(boolean isCircle) { mOptionBundle.putBoolean(EXTRA_CIRCLE_DIMMED_LAYER, isCircle); return this; } /** * @param show - set to true if you want to see a crop frame rectangle on top of an image */ public Options setShowCropFrame(boolean show) { mOptionBundle.putBoolean(EXTRA_SHOW_CROP_FRAME, show); return this; } /** * @param color - desired color of crop frame */ public Options setCropFrameColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_CROP_FRAME_COLOR, color); return this; } /** * @param width - desired width of crop frame line in pixels */ public Options setCropFrameStrokeWidth(@IntRange(from = 0) int width) { mOptionBundle.putInt(EXTRA_CROP_FRAME_STROKE_WIDTH, width); return this; } /** * @param show - set to true if you want to see a crop grid/guidelines on top of an image */ public Options setShowCropGrid(boolean show) { mOptionBundle.putBoolean(EXTRA_SHOW_CROP_GRID, show); return this; } public Options setScaleEnabled(boolean scaleEnabled) { mOptionBundle.putBoolean(EXTRA_SCALE, scaleEnabled); return this; } public Options setRotateEnabled(boolean rotateEnabled) { mOptionBundle.putBoolean(EXTRA_ROTATE, rotateEnabled); return this; } /** * @param isDragFrame - 是否可拖动裁剪框 */ public Options setDragFrameEnabled(boolean isDragFrame) { mOptionBundle.putBoolean(EXTRA_DRAG_CROP_FRAME, isDragFrame); return this; } /** * @param count - crop grid rows count. */ public Options setCropGridRowCount(@IntRange(from = 0) int count) { mOptionBundle.putInt(EXTRA_CROP_GRID_ROW_COUNT, count); return this; } /** * @param count - crop grid columns count. */ public Options setCropGridColumnCount(@IntRange(from = 0) int count) { mOptionBundle.putInt(EXTRA_CROP_GRID_COLUMN_COUNT, count); return this; } /** * @param color - desired color of crop grid/guidelines */ public Options setCropGridColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_CROP_GRID_COLOR, color); return this; } /** * @param width - desired width of crop grid lines in pixels */ public Options setCropGridStrokeWidth(@IntRange(from = 0) int width) { mOptionBundle.putInt(EXTRA_CROP_GRID_STROKE_WIDTH, width); return this; } /** * @param color - desired resolved color of the toolbar */ public Options setToolbarColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_TOOL_BAR_COLOR, color); return this; } /** * @param color - desired resolved color of the statusbar */ public Options setStatusBarColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_STATUS_BAR_COLOR, color); return this; } /** * @param color - desired resolved color of the active and selected widget (default is orange) and progress wheel middle line */ public Options setActiveWidgetColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_COLOR_WIDGET_ACTIVE, color); return this; } /** * @param color - desired resolved color of Toolbar text and buttons (default is darker orange) */ public Options setToolbarWidgetColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_WIDGET_COLOR_TOOLBAR, color); return this; } /** * @param openWhiteStatusBar - Change the status bar font color */ public Options isOpenWhiteStatusBar(boolean openWhiteStatusBar) { mOptionBundle.putBoolean(EXTRA_UCROP_WIDGET_CROP_OPEN_WHITE_STATUSBAR, openWhiteStatusBar); return this; } /** * @param text - desired text for Toolbar title */ public Options setToolbarTitle(@Nullable String text) { mOptionBundle.putString(EXTRA_UCROP_TITLE_TEXT_TOOLBAR, text); return this; } /** * @param drawable - desired drawable for the Toolbar left cancel icon */ public Options setToolbarCancelDrawable(@DrawableRes int drawable) { mOptionBundle.putInt(EXTRA_UCROP_WIDGET_CANCEL_DRAWABLE, drawable); return this; } /** * @param drawable - desired drawable for the Toolbar right crop icon */ public Options setToolbarCropDrawable(@DrawableRes int drawable) { mOptionBundle.putInt(EXTRA_UCROP_WIDGET_CROP_DRAWABLE, drawable); return this; } /** * @param color - desired resolved color of logo fill (default is darker grey) */ public Options setLogoColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_LOGO_COLOR, color); return this; } /** * @param -set cuts path */ public Options setCutListData(ArrayList list) { mOptionBundle.putSerializable(EXTRA_CUT_CROP, list); return this; } /** * @param enabled - set to true to let user resize crop bounds (disabled by default) */ public Options setFreeStyleCropEnabled(boolean enabled) { mOptionBundle.putBoolean(EXTRA_FREE_STYLE_CROP, enabled); return this; } /** * @param statusFont - Set status bar black */ @Deprecated public Options setStatusFont(boolean statusFont) { mOptionBundle.putBoolean(EXTRA_FREE_STATUS_FONT, statusFont); return this; } /** * @param activityCropExitAnimation activity exit animation */ public Options setCropExitAnimation(@AnimRes int activityCropExitAnimation) { mOptionBundle.putInt(EXTRA_WINDOW_EXIT_ANIMATION, activityCropExitAnimation); return this; } /** * @param navBarColor set NavBar Color */ public Options setNavBarColor(@ColorInt int navBarColor) { mOptionBundle.putInt(EXTRA_NAV_BAR_COLOR, navBarColor); return this; } /** * Pass an ordered list of desired aspect ratios that should be available for a user. * * @param selectedByDefault - index of aspect ratio option that is selected by default (starts with 0). * @param aspectRatio - list of aspect ratio options that are available to user */ public Options setAspectRatioOptions(int selectedByDefault, AspectRatio... aspectRatio) { if (selectedByDefault > aspectRatio.length) { throw new IllegalArgumentException(String.format(Locale.US, "Index [selectedByDefault = %d] cannot be higher than aspect ratio options count [count = %d].", selectedByDefault, aspectRatio.length)); } mOptionBundle.putInt(EXTRA_ASPECT_RATIO_SELECTED_BY_DEFAULT, selectedByDefault); mOptionBundle.putParcelableArrayList(EXTRA_ASPECT_RATIO_OPTIONS, new ArrayList(Arrays.asList(aspectRatio))); return this; } /** * @param color - desired background color that should be applied to the root view */ public Options setRootViewBackgroundColor(@ColorInt int color) { mOptionBundle.putInt(EXTRA_UCROP_ROOT_VIEW_BACKGROUND_COLOR, color); return this; } /** * Set an aspect ratio for crop bounds. * User won't see the menu with other ratios options. * * @param x aspect ratio X * @param y aspect ratio Y */ public Options withAspectRatio(float x, float y) { mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_X, x); mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_Y, y); return this; } /** * Set an aspect ratio for crop bounds that is evaluated from source image width and height. * User won't see the menu with other ratios options. */ public Options useSourceImageAspectRatio() { mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_X, 0); mOptionBundle.putFloat(EXTRA_ASPECT_RATIO_Y, 0); return this; } /** * Set maximum size for result cropped image. * * @param width max cropped image width * @param height max cropped image height */ public Options withMaxResultSize(int width, int height) { mOptionBundle.putInt(EXTRA_MAX_SIZE_X, width); mOptionBundle.putInt(EXTRA_MAX_SIZE_Y, height); return this; } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/callback/Callback.kt ================================================ @file:JvmName("Callback") package com.matisse.ucrop.callback import android.graphics.Bitmap import android.graphics.RectF import android.net.Uri import com.matisse.ucrop.model.ExifInfo interface BitmapCropCallback { fun onBitmapCropped( resultUri: Uri, offsetX: Int, offsetY: Int, imageWidth: Int, imageHeight: Int ) fun onCropFailure(t: Throwable) } interface BitmapLoadCallback { fun onBitmapLoaded(bitmap: Bitmap, exifInfo: ExifInfo, imageInputUri: Uri, imageOutputUri: Uri?) fun onFailure(bitmapWorkerException: Exception) } interface BitmapLoadShowCallback { fun onBitmapLoaded(bitmap: Bitmap) fun onFailure(bitmapWorkerException: Exception) } interface CropBoundsChangeListener { fun onCropAspectRatioChanged(cropRatio: Float) } interface OverlayViewChangeListener { fun onCropRectUpdated(cropRect: RectF) } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/immersion/CropImmersiveManage.java ================================================ package com.matisse.ucrop.immersion; import android.graphics.Color; import android.os.Build; import android.view.Window; import android.view.WindowManager; import androidx.appcompat.app.AppCompatActivity; /** * @author:luck * @data:2018/3/28 下午1:00 * @描述: 沉浸式相关 */ public class CropImmersiveManage { /** * 判定是否使用沉浸式 */ public static boolean immersiveUseful() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return true; } return false; } /** * 注意:使用最好将布局xml 跟布局加入 android:fitsSystemWindows="true" ,这样可以避免有些手机上布局顶边的问题 * * @param baseActivity 这个会留出来状态栏和底栏的空白 * @param statusBarColor 状态栏的颜色 * @param navigationBarColor 导航栏的颜色 * @param isDarkStatusBarIcon 状态栏图标颜色是否是深(黑)色 false状态栏图标颜色为白色 */ public static void immersiveAboveAPI23(AppCompatActivity baseActivity, int statusBarColor, int navigationBarColor, boolean isDarkStatusBarIcon) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { immersiveAboveAPI23(baseActivity, false, false, statusBarColor, navigationBarColor, isDarkStatusBarIcon); } } /** * @param baseActivity * @param statusBarColor 状态栏的颜色 * @param navigationBarColor 导航栏的颜色 */ public static void immersiveAboveAPI23(AppCompatActivity baseActivity, boolean isMarginStatusBar , boolean isMarginNavigationBar, int statusBarColor, int navigationBarColor, boolean isDarkStatusBarIcon) { try { Window window = baseActivity.getWindow(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { //4.4版本及以上 5.0版本及以下 window.setFlags( WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (isMarginStatusBar && isMarginNavigationBar) { //5.0版本及以上 window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); CropLightStatusBarUtils.setLightStatusBar(baseActivity, isMarginStatusBar , isMarginNavigationBar , statusBarColor == Color.TRANSPARENT , isDarkStatusBarIcon); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); } else if (!isMarginStatusBar && !isMarginNavigationBar) { window.requestFeature(Window.FEATURE_NO_TITLE); window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); CropLightStatusBarUtils.setLightStatusBar(baseActivity, isMarginStatusBar , isMarginNavigationBar , statusBarColor == Color.TRANSPARENT , isDarkStatusBarIcon); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); } else if (!isMarginStatusBar && isMarginNavigationBar) { window.requestFeature(Window.FEATURE_NO_TITLE); window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); CropLightStatusBarUtils.setLightStatusBar(baseActivity, isMarginStatusBar , isMarginNavigationBar , statusBarColor == Color.TRANSPARENT , isDarkStatusBarIcon); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); } else { //留出来状态栏 不留出来导航栏 没找到办法。。 return; } window.setStatusBarColor(statusBarColor); window.setNavigationBarColor(navigationBarColor); } } catch (Exception e) { e.printStackTrace(); } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/immersion/CropLightStatusBarUtils.java ================================================ package com.matisse.ucrop.immersion; import android.annotation.TargetApi; import android.app.Activity; import android.os.Build; import android.view.View; import android.view.Window; import android.view.WindowManager; import java.lang.reflect.Field; import java.lang.reflect.Method; /** * @author:luck * @data:2018/3/28 下午1:01 * @描述: 沉浸式 */ public class CropLightStatusBarUtils { public static void setLightStatusBarAboveAPI23(Activity activity, boolean isMarginStatusBar , boolean isMarginNavigationBar, boolean isTransStatusBar, boolean dark) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); } } public static void setLightStatusBar(Activity activity, boolean dark) { setLightStatusBar(activity, false, false, false, dark); } public static void setLightStatusBar(Activity activity, boolean isMarginStatusBar , boolean isMarginNavigationBar, boolean isTransStatusBar, boolean dark) { switch (CropRomUtils.getLightStatausBarAvailableRomType()) { case CropRomUtils.AvailableRomType.MIUI: if (CropRomUtils.getMIUIVersionCode() >= 7) { setAndroidNativeLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); } else { setMIUILightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); } break; case CropRomUtils.AvailableRomType.FLYME: setFlymeLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); break; case CropRomUtils.AvailableRomType.ANDROID_NATIVE: setAndroidNativeLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); break; case CropRomUtils.AvailableRomType.NA: // N/A do nothing break; } } private static boolean setMIUILightStatusBar(Activity activity, boolean isMarginStatusBar , boolean isMarginNavigationBar, boolean isTransStatusBar, boolean darkmode) { initStatusBarStyle(activity, isMarginStatusBar, isMarginNavigationBar); Class clazz = activity.getWindow().getClass(); try { int darkModeFlag = 0; Class layoutParams = Class.forName("android.view.MiuiWindowManager$LayoutParams"); Field field = layoutParams.getField("EXTRA_FLAG_STATUS_BAR_DARK_MODE"); darkModeFlag = field.getInt(layoutParams); Method extraFlagField = clazz.getMethod("setExtraFlags", int.class, int.class); extraFlagField.invoke(activity.getWindow(), darkmode ? darkModeFlag : 0, darkModeFlag); return true; } catch (Exception e) { setAndroidNativeLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, darkmode); } return false; } private static boolean setFlymeLightStatusBar(Activity activity, boolean isMarginStatusBar , boolean isMarginNavigationBar, boolean isTransStatusBar, boolean dark) { boolean result = false; if (activity != null) { initStatusBarStyle(activity, isMarginStatusBar, isMarginNavigationBar); try { WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); Field darkFlag = WindowManager.LayoutParams.class .getDeclaredField("MEIZU_FLAG_DARK_STATUS_BAR_ICON"); Field meizuFlags = WindowManager.LayoutParams.class .getDeclaredField("meizuFlags"); darkFlag.setAccessible(true); meizuFlags.setAccessible(true); int bit = darkFlag.getInt(null); int value = meizuFlags.getInt(lp); if (dark) { value |= bit; } else { value &= ~bit; } meizuFlags.setInt(lp, value); activity.getWindow().setAttributes(lp); result = true; if (CropRomUtils.getFlymeVersion() >= 7) { setAndroidNativeLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); } } catch (Exception e) { setAndroidNativeLightStatusBar(activity, isMarginStatusBar, isMarginNavigationBar, isTransStatusBar, dark); } } return result; } @TargetApi(11) private static void setAndroidNativeLightStatusBar(Activity activity, boolean isMarginStatusBar , boolean isMarginNavigationBar, boolean isTransStatusBar, boolean isDarkStatusBarIcon) { try { if (isTransStatusBar) { Window window = activity.getWindow(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (isMarginStatusBar && isMarginNavigationBar) { //5.0版本及以上 if (isDarkStatusBarIcon && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } else { window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } } else if (!isMarginStatusBar && !isMarginNavigationBar) { if (isDarkStatusBarIcon && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN // | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } else { window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN // | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } } else if (!isMarginStatusBar && isMarginNavigationBar) { if (isDarkStatusBarIcon && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } else { window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } } else { //留出来状态栏 不留出来导航栏 没找到办法。。 return; } } } else { View decor = activity.getWindow().getDecorView(); if (isDarkStatusBarIcon && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } else { // We want to change tint color to white again. // You can also record the flags in advance so that you can turn UI back completely if // you have set other flags before, such as translucent or full screen. decor.setSystemUiVisibility(0); } } } catch (Exception e) { } } private static void initStatusBarStyle(Activity activity, boolean isMarginStatusBar , boolean isMarginNavigationBar) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { if (isMarginStatusBar && isMarginNavigationBar) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } else if (!isMarginStatusBar && !isMarginNavigationBar) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } else if (!isMarginStatusBar && isMarginNavigationBar) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } else { //留出来状态栏 不留出来导航栏 没找到办法。。 } } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/immersion/CropRomUtils.java ================================================ package com.matisse.ucrop.immersion; import android.os.Build; import android.text.TextUtils; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.regex.Pattern; /** * @author:luck * @data:2018/3/28 下午1:02 * @描述: Rom版本管理 */ public class CropRomUtils { public class AvailableRomType { public static final int MIUI = 1; public static final int FLYME = 2; public static final int ANDROID_NATIVE = 3; public static final int NA = 4; } private static Integer romType; public static int getLightStatausBarAvailableRomType() { if (romType != null) { return romType; } if (isMIUIV6OrAbove()) { romType = AvailableRomType.MIUI; return romType; } if (isFlymeV4OrAbove()) { romType = AvailableRomType.FLYME; return romType; } if (isAndroid5OrAbove()) { romType = AvailableRomType.ANDROID_NATIVE; return romType; } romType = AvailableRomType.NA; return romType; } //Flyme V4的displayId格式为 [Flyme OS 4.x.x.xA] //Flyme V5的displayId格式为 [Flyme 5.x.x.x beta] private static boolean isFlymeV4OrAbove() { return (getFlymeVersion() >= 4); } //Flyme V4的displayId格式为 [Flyme OS 4.x.x.xA] //Flyme V5的displayId格式为 [Flyme 5.x.x.x beta] public static int getFlymeVersion() { String displayId = Build.DISPLAY; if (!TextUtils.isEmpty(displayId) && displayId.contains("Flyme")) { displayId = displayId.replaceAll("Flyme", ""); displayId = displayId.replaceAll("OS", ""); displayId = displayId.replaceAll(" ", ""); String version = displayId.substring(0, 1); if (version != null) { return stringToInt(version); } } return 0; } //MIUI V6对应的versionCode是4 //MIUI V7对应的versionCode是5 private static boolean isMIUIV6OrAbove() { String miuiVersionCodeStr = getSystemProperty("ro.miui.ui.version.code"); if (!TextUtils.isEmpty(miuiVersionCodeStr)) { try { int miuiVersionCode = Integer.parseInt(miuiVersionCodeStr); if (miuiVersionCode >= 4) { return true; } } catch (Exception e) { } } return false; } public static int getMIUIVersionCode() { String miuiVersionCodeStr = getSystemProperty("ro.miui.ui.version.code"); int miuiVersionCode = 0; if (!TextUtils.isEmpty(miuiVersionCodeStr)) { try { miuiVersionCode = Integer.parseInt(miuiVersionCodeStr); return miuiVersionCode; } catch (Exception e) { } } return miuiVersionCode; } //Android Api 23以上 private static boolean isAndroid5OrAbove() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return true; } return false; } public static String getSystemProperty(String propName) { String line; BufferedReader input = null; try { Process p = Runtime.getRuntime().exec("getprop " + propName); input = new BufferedReader(new InputStreamReader(p.getInputStream()), 1024); line = input.readLine(); input.close(); } catch (IOException ex) { return null; } finally { if (input != null) { try { input.close(); } catch (IOException e) { } } } return line; } public static int stringToInt(String str) { Pattern pattern = Pattern.compile("^[-\\+]?[\\d]+$"); if (pattern.matcher(str).matches()) { return Integer.valueOf(str); } return 0; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/model/AspectRatio.java ================================================ package com.matisse.ucrop.model; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.Nullable; /** * Created by Oleksii Shliama [https://github.com/shliama] on 6/24/16. */ public class AspectRatio implements Parcelable { @Nullable private final String mAspectRatioTitle; private final float mAspectRatioX; private final float mAspectRatioY; protected AspectRatio(Parcel in) { mAspectRatioTitle = in.readString(); mAspectRatioX = in.readFloat(); mAspectRatioY = in.readFloat(); } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(mAspectRatioTitle); dest.writeFloat(mAspectRatioX); dest.writeFloat(mAspectRatioY); } @Override public int describeContents() { return 0; } public static final Creator CREATOR = new Creator() { @Override public AspectRatio createFromParcel(Parcel in) { return new AspectRatio(in); } @Override public AspectRatio[] newArray(int size) { return new AspectRatio[size]; } }; @Nullable public String getAspectRatioTitle() { return mAspectRatioTitle; } public float getAspectRatioX() { return mAspectRatioX; } public float getAspectRatioY() { return mAspectRatioY; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/model/CropParameters.java ================================================ package com.matisse.ucrop.model; import android.graphics.Bitmap; import android.net.Uri; /** * Created by Oleksii Shliama [https://github.com/shliama] on 6/21/16. */ public class CropParameters { private int mMaxResultImageSizeX, mMaxResultImageSizeY; private Bitmap.CompressFormat mCompressFormat; private int mCompressQuality; private Uri mImageInputUri; private String mImageOutputPath; private ExifInfo mExifInfo; public CropParameters(int maxResultImageSizeX, int maxResultImageSizeY, Bitmap.CompressFormat compressFormat, int compressQuality, Uri imageInputUri, String imageOutputPath, ExifInfo exifInfo) { mMaxResultImageSizeX = maxResultImageSizeX; mMaxResultImageSizeY = maxResultImageSizeY; mCompressFormat = compressFormat; mCompressQuality = compressQuality; mImageInputUri = imageInputUri; mImageOutputPath = imageOutputPath; mExifInfo = exifInfo; } public int getMaxResultImageSizeX() { return mMaxResultImageSizeX; } public int getMaxResultImageSizeY() { return mMaxResultImageSizeY; } public Bitmap.CompressFormat getCompressFormat() { return mCompressFormat; } public int getCompressQuality() { return mCompressQuality; } public Uri getImageInputUri() { return mImageInputUri; } public String getImageOutputPath() { return mImageOutputPath; } public ExifInfo getExifInfo() { return mExifInfo; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/model/CutInfo.java ================================================ package com.matisse.ucrop.model; import java.io.Serializable; /** * @author:luck * @data:2017/05/30 晚上23:00 * @描述: CutInfo */ public class CutInfo implements Serializable { /** * File ID */ private long id; /** * 原图 */ private String path; /** * 裁剪路径 */ private String cutPath; /** * Android Q特有地址 */ private String androidQToPath; /** * 裁剪比例 */ private int offsetX; /** * 裁剪比例 */ private int offsetY; /** * 图片宽 */ private int imageWidth; /** * 图片高 */ private int imageHeight; /** * 是否裁剪 */ private boolean isCut; /** * 资源类型 */ private String mimeType; private float resultAspectRatio; public CutInfo() { } public CutInfo(String path, boolean isCut) { this.path = path; this.isCut = isCut; } public String getPath() { return path; } public void setPath(String path) { this.path = path; } public String getCutPath() { return cutPath; } public void setCutPath(String cutPath) { this.cutPath = cutPath; } public int getOffsetX() { return offsetX; } public void setOffsetX(int offsetX) { this.offsetX = offsetX; } public int getOffsetY() { return offsetY; } public void setOffsetY(int offsetY) { this.offsetY = offsetY; } public int getImageWidth() { return imageWidth; } public void setImageWidth(int imageWidth) { this.imageWidth = imageWidth; } public int getImageHeight() { return imageHeight; } public void setImageHeight(int imageHeight) { this.imageHeight = imageHeight; } public float getResultAspectRatio() { return resultAspectRatio; } public void setResultAspectRatio(float resultAspectRatio) { this.resultAspectRatio = resultAspectRatio; } public String getMimeType() { return mimeType; } public void setMimeType(String mimeType) { this.mimeType = mimeType; } public boolean isCut() { return isCut; } public void setCut(boolean cut) { isCut = cut; } public String getAndroidQToPath() { return androidQToPath; } public void setAndroidQToPath(String androidQToPath) { this.androidQToPath = androidQToPath; } public long getId() { return id; } public void setId(long id) { this.id = id; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/model/ExifInfo.java ================================================ package com.matisse.ucrop.model; /** * Created by Oleksii Shliama [https://github.com/shliama] on 6/21/16. */ public class ExifInfo { private int mExifOrientation; private int mExifDegrees; private int mExifTranslation; public ExifInfo(int exifOrientation, int exifDegrees, int exifTranslation) { mExifOrientation = exifOrientation; mExifDegrees = exifDegrees; mExifTranslation = exifTranslation; } public int getExifOrientation() { return mExifOrientation; } public int getExifDegrees() { return mExifDegrees; } public int getExifTranslation() { return mExifTranslation; } public void setExifOrientation(int exifOrientation) { mExifOrientation = exifOrientation; } public void setExifDegrees(int exifDegrees) { mExifDegrees = exifDegrees; } public void setExifTranslation(int exifTranslation) { mExifTranslation = exifTranslation; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ExifInfo exifInfo = (ExifInfo) o; if (mExifOrientation != exifInfo.mExifOrientation) return false; if (mExifDegrees != exifInfo.mExifDegrees) return false; return mExifTranslation == exifInfo.mExifTranslation; } @Override public int hashCode() { int result = mExifOrientation; result = 31 * result + mExifDegrees; result = 31 * result + mExifTranslation; return result; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/model/ImageState.java ================================================ package com.matisse.ucrop.model; import android.graphics.RectF; /** * Created by Oleksii Shliama [https://github.com/shliama] on 6/21/16. */ public class ImageState { private RectF mCropRect; private RectF mCurrentImageRect; private float mCurrentScale, mCurrentAngle; public ImageState(RectF cropRect, RectF currentImageRect, float currentScale, float currentAngle) { mCropRect = cropRect; mCurrentImageRect = currentImageRect; mCurrentScale = currentScale; mCurrentAngle = currentAngle; } public RectF getCropRect() { return mCropRect; } public RectF getCurrentImageRect() { return mCurrentImageRect; } public float getCurrentScale() { return mCurrentScale; } public float getCurrentAngle() { return mCurrentAngle; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/task/BitmapCropTask.java ================================================ package com.matisse.ucrop.task; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Matrix; import android.graphics.RectF; import android.media.ExifInterface; import android.net.Uri; import android.os.AsyncTask; import android.os.ParcelFileDescriptor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.matisse.ucrop.callback.BitmapCropCallback; import com.matisse.ucrop.model.CropParameters; import com.matisse.ucrop.model.ExifInfo; import com.matisse.ucrop.model.ImageState; import com.matisse.ucrop.util.BitmapLoadUtils; import com.matisse.ucrop.util.FileUtils; import com.matisse.ucrop.util.ImageHeaderParser; import com.matisse.ucrop.util.VersionUtils; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStream; import java.lang.ref.WeakReference; /** * Crops part of image that fills the crop bounds. *

* First image is downscaled if max size was set and if resulting image is larger that max size. * Then image is rotated accordingly. * Finally new Bitmap object is created and saved to file. */ public class BitmapCropTask extends AsyncTask { private static final String TAG = "BitmapCropTask"; private final WeakReference mContext; private Bitmap mViewBitmap; private final RectF mCropRect; private final RectF mCurrentImageRect; private float mCurrentScale, mCurrentAngle; private final int mMaxResultImageSizeX, mMaxResultImageSizeY; private final Bitmap.CompressFormat mCompressFormat; private final int mCompressQuality; private final Uri mImageInputUri; private final String mImageOutputPath; private final ExifInfo mExifInfo; private final BitmapCropCallback mCropCallback; private int mCroppedImageWidth, mCroppedImageHeight; private int cropOffsetX, cropOffsetY; private boolean mIsCircleCrop; public BitmapCropTask(@NonNull Context context, @Nullable Bitmap viewBitmap , @NonNull ImageState imageState, @NonNull CropParameters cropParameters , boolean isCircleCrop, @Nullable BitmapCropCallback cropCallback) { mContext = new WeakReference<>(context); mViewBitmap = viewBitmap; mCropRect = imageState.getCropRect(); mCurrentImageRect = imageState.getCurrentImageRect(); mCurrentScale = imageState.getCurrentScale(); mCurrentAngle = imageState.getCurrentAngle(); mMaxResultImageSizeX = cropParameters.getMaxResultImageSizeX(); mMaxResultImageSizeY = cropParameters.getMaxResultImageSizeY(); mCompressFormat = cropParameters.getCompressFormat(); mCompressQuality = cropParameters.getCompressQuality(); mIsCircleCrop = isCircleCrop; mImageInputUri = cropParameters.getImageInputUri(); mImageOutputPath = cropParameters.getImageOutputPath(); mExifInfo = cropParameters.getExifInfo(); mCropCallback = cropCallback; } @Override @Nullable protected Throwable doInBackground(Void... params) { if (mViewBitmap == null) { return new NullPointerException("ViewBitmap is null"); } else if (mViewBitmap.isRecycled()) { return new NullPointerException("ViewBitmap is recycled"); } else if (mCurrentImageRect.isEmpty()) { return new NullPointerException("CurrentImageRect is empty"); } try { crop(); mViewBitmap = null; } catch (Throwable throwable) { return throwable; } return null; } private boolean crop() throws IOException { // Downsize if needed if (mMaxResultImageSizeX > 0 && mMaxResultImageSizeY > 0) { float cropWidth = mCropRect.width() / mCurrentScale; float cropHeight = mCropRect.height() / mCurrentScale; if (cropWidth > mMaxResultImageSizeX || cropHeight > mMaxResultImageSizeY) { float scaleX = mMaxResultImageSizeX / cropWidth; float scaleY = mMaxResultImageSizeY / cropHeight; float resizeScale = Math.min(scaleX, scaleY); Bitmap resizedBitmap = Bitmap.createScaledBitmap(mViewBitmap, Math.round(mViewBitmap.getWidth() * resizeScale), Math.round(mViewBitmap.getHeight() * resizeScale), false); if (mViewBitmap != resizedBitmap) { mViewBitmap.recycle(); } mViewBitmap = resizedBitmap; mCurrentScale /= resizeScale; } } // Rotate if needed if (mCurrentAngle != 0) { Matrix tempMatrix = new Matrix(); tempMatrix.setRotate(mCurrentAngle, mViewBitmap.getWidth() / 2, mViewBitmap.getHeight() / 2); Bitmap rotatedBitmap = Bitmap.createBitmap(mViewBitmap, 0, 0, mViewBitmap.getWidth(), mViewBitmap.getHeight(), tempMatrix, true); if (mViewBitmap != rotatedBitmap) { mViewBitmap.recycle(); } mViewBitmap = rotatedBitmap; } cropOffsetX = Math.round((mCropRect.left - mCurrentImageRect.left) / mCurrentScale); cropOffsetY = Math.round((mCropRect.top - mCurrentImageRect.top) / mCurrentScale); mCroppedImageWidth = Math.round(mCropRect.width() / mCurrentScale); mCroppedImageHeight = Math.round(mCropRect.height() / mCurrentScale); boolean shouldCrop = shouldCrop(mCroppedImageWidth, mCroppedImageHeight); boolean isAndroidQ = VersionUtils.isAndroidQ(); if (shouldCrop) { ExifInterface originalExif; if (isAndroidQ && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { ParcelFileDescriptor parcelFileDescriptor = mContext.get().getContentResolver().openFileDescriptor(mImageInputUri, "r"); FileInputStream inputStream = new FileInputStream(parcelFileDescriptor.getFileDescriptor()); originalExif = new ExifInterface(inputStream); } else { originalExif = new ExifInterface(mImageInputUri.getPath()); } if (mIsCircleCrop) { int cropRadio = Math.min(mCroppedImageWidth, mCroppedImageHeight); saveImage(Bitmap.createBitmap(mViewBitmap, cropOffsetX, cropOffsetY, cropRadio, cropRadio)); } else { saveImage(Bitmap.createBitmap(mViewBitmap, cropOffsetX, cropOffsetY, mCroppedImageWidth, mCroppedImageHeight)); } if (mCompressFormat.equals(Bitmap.CompressFormat.JPEG)) { if (mIsCircleCrop) { int cropRadio = Math.min(mCroppedImageWidth, mCroppedImageHeight); ImageHeaderParser.copyExif(originalExif, cropRadio, cropRadio, mImageOutputPath); } else { ImageHeaderParser.copyExif(originalExif, mCroppedImageWidth, mCroppedImageHeight, mImageOutputPath); } } return true; } else { if (isAndroidQ) { ParcelFileDescriptor parcelFileDescriptor = mContext.get().getContentResolver().openFileDescriptor(mImageInputUri, "r"); FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); FileInputStream inputStream = new FileInputStream(fileDescriptor); FileUtils.copyFile(inputStream, mImageOutputPath); } else { FileUtils.copyFile(mImageInputUri.getPath(), mImageOutputPath); } return true; } } private void saveImage(@NonNull Bitmap croppedBitmap) throws FileNotFoundException { Context context = mContext.get(); if (context == null) { return; } OutputStream outputStream = null; try { outputStream = context.getContentResolver().openOutputStream(Uri.fromFile(new File(mImageOutputPath))); croppedBitmap.compress(mCompressFormat, mCompressQuality, outputStream); croppedBitmap.recycle(); } finally { BitmapLoadUtils.close(outputStream); } } /** * Check whether an image should be cropped at all or just file can be copied to the destination path. * For each 1000 pixels there is one pixel of error due to matrix calculations etc. * * @param width - crop area width * @param height - crop area height * @return - true if image must be cropped, false - if original image fits requirements */ private boolean shouldCrop(int width, int height) { int pixelError = 1; pixelError += Math.round(Math.max(width, height) / 1000f); return (mMaxResultImageSizeX > 0 && mMaxResultImageSizeY > 0) || Math.abs(mCropRect.left - mCurrentImageRect.left) > pixelError || Math.abs(mCropRect.top - mCurrentImageRect.top) > pixelError || Math.abs(mCropRect.bottom - mCurrentImageRect.bottom) > pixelError || Math.abs(mCropRect.right - mCurrentImageRect.right) > pixelError; } @Override protected void onPostExecute(@Nullable Throwable t) { if (mCropCallback != null) { if (t == null) { Uri uri = Uri.fromFile(new File(mImageOutputPath)); mCropCallback.onBitmapCropped(uri, cropOffsetX, cropOffsetY, mCroppedImageWidth, mCroppedImageHeight); } else { mCropCallback.onCropFailure(t); } } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/task/BitmapLoadShowTask.java ================================================ package com.matisse.ucrop.task; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.ParcelFileDescriptor; import android.util.Log; import androidx.annotation.NonNull; import com.matisse.ucrop.callback.BitmapLoadShowCallback; import com.matisse.ucrop.model.ExifInfo; import com.matisse.ucrop.util.BitmapLoadUtils; import java.io.FileDescriptor; import java.io.FileNotFoundException; /** * Creates and returns a Bitmap for a given Uri(String url). * inSampleSize is calculated based on requiredWidth property. However can be adjusted if OOM occurs. * If any EXIF config is found - bitmap is transformed properly. */ public class BitmapLoadShowTask extends AsyncTask { private static final String TAG = "BitmapWorkerTask"; private final Context mContext; private Uri mInputUri; private final int mRequiredWidth; private final int mRequiredHeight; private final BitmapLoadShowCallback mBitmapLoadShowCallback; public static class BitmapWorkerResult { Bitmap mBitmapResult; ExifInfo mExifInfo; Exception mBitmapWorkerException; public BitmapWorkerResult(@NonNull Bitmap bitmapResult, @NonNull ExifInfo exifInfo) { mBitmapResult = bitmapResult; mExifInfo = exifInfo; } public BitmapWorkerResult(@NonNull Exception bitmapWorkerException) { mBitmapWorkerException = bitmapWorkerException; } } public BitmapLoadShowTask(@NonNull Context context, @NonNull Uri inputUri, int requiredWidth, int requiredHeight, BitmapLoadShowCallback loadCallback) { mContext = context; mInputUri = inputUri; mRequiredWidth = requiredWidth; mRequiredHeight = requiredHeight; mBitmapLoadShowCallback = loadCallback; } @Override @NonNull protected BitmapWorkerResult doInBackground(Void... params) { if (mInputUri == null) { return new BitmapWorkerResult(new NullPointerException("Input Uri cannot be null")); } final ParcelFileDescriptor parcelFileDescriptor; try { parcelFileDescriptor = mContext.getContentResolver().openFileDescriptor(mInputUri, "r"); } catch (FileNotFoundException e) { return new BitmapWorkerResult(e); } final FileDescriptor fileDescriptor; if (parcelFileDescriptor != null) { fileDescriptor = parcelFileDescriptor.getFileDescriptor(); } else { return new BitmapWorkerResult(new NullPointerException("ParcelFileDescriptor was null for given Uri: [" + mInputUri + "]")); } final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options); if (options.outWidth == -1 || options.outHeight == -1) { return new BitmapWorkerResult(new IllegalArgumentException("Bounds for bitmap could not be retrieved from the Uri: [" + mInputUri + "]")); } options.inSampleSize = BitmapLoadUtils.calculateInSampleSize(options, mRequiredWidth, mRequiredHeight); options.inJustDecodeBounds = false; Bitmap decodeSampledBitmap = null; boolean decodeAttemptSuccess = false; while (!decodeAttemptSuccess) { try { decodeSampledBitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options); decodeAttemptSuccess = true; } catch (OutOfMemoryError error) { Log.e(TAG, "doInBackground: BitmapFactory.decodeFileDescriptor: ", error); options.inSampleSize *= 2; } } if (decodeSampledBitmap == null) { return new BitmapWorkerResult(new IllegalArgumentException("Bitmap could not be decoded from the Uri: [" + mInputUri + "]")); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { BitmapLoadUtils.close(parcelFileDescriptor); } int exifOrientation = BitmapLoadUtils.getExifOrientation(mContext, mInputUri); int exifDegrees = BitmapLoadUtils.exifToDegrees(exifOrientation); int exifTranslation = BitmapLoadUtils.exifToTranslation(exifOrientation); ExifInfo exifInfo = new ExifInfo(exifOrientation, exifDegrees, exifTranslation); Matrix matrix = new Matrix(); if (exifDegrees != 0) { matrix.preRotate(exifDegrees); } if (exifTranslation != 1) { matrix.postScale(exifTranslation, 1); } if (!matrix.isIdentity()) { return new BitmapWorkerResult(BitmapLoadUtils.transformBitmap(decodeSampledBitmap, matrix), exifInfo); } return new BitmapWorkerResult(decodeSampledBitmap, exifInfo); } @Override protected void onPostExecute(@NonNull BitmapWorkerResult result) { if (result.mBitmapWorkerException == null) { mBitmapLoadShowCallback.onBitmapLoaded(result.mBitmapResult); } else { mBitmapLoadShowCallback.onFailure(result.mBitmapWorkerException); } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/task/BitmapLoadTask.java ================================================ package com.matisse.ucrop.task; import android.Manifest.permission; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.ParcelFileDescriptor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import android.text.TextUtils; import android.util.Log; import com.matisse.ucrop.callback.BitmapLoadCallback; import com.matisse.ucrop.model.ExifInfo; import com.matisse.ucrop.util.BitmapLoadUtils; import com.matisse.ucrop.util.VersionUtils; import com.matisse.ucrop.util.FileUtils; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileDescriptor; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; /** * Creates and returns a Bitmap for a given Uri(String url). * inSampleSize is calculated based on requiredWidth property. However can be adjusted if OOM occurs. * If any EXIF config is found - bitmap is transformed properly. */ public class BitmapLoadTask extends AsyncTask { private static final String TAG = "BitmapWorkerTask"; private final Context mContext; private Uri mInputUri; private Uri mOutputUri; private final int mRequiredWidth; private final int mRequiredHeight; private final BitmapLoadCallback mBitmapLoadCallback; public static class BitmapWorkerResult { Bitmap mBitmapResult; ExifInfo mExifInfo; Exception mBitmapWorkerException; public BitmapWorkerResult(@NonNull Bitmap bitmapResult, @NonNull ExifInfo exifInfo) { mBitmapResult = bitmapResult; mExifInfo = exifInfo; } public BitmapWorkerResult(@NonNull Exception bitmapWorkerException) { mBitmapWorkerException = bitmapWorkerException; } } public BitmapLoadTask(@NonNull Context context, @NonNull Uri inputUri, @Nullable Uri outputUri, int requiredWidth, int requiredHeight, BitmapLoadCallback loadCallback) { mContext = context; mInputUri = inputUri; mOutputUri = outputUri; mRequiredWidth = requiredWidth; mRequiredHeight = requiredHeight; mBitmapLoadCallback = loadCallback; } @Override @NonNull protected BitmapWorkerResult doInBackground(Void... params) { if (mInputUri == null) { return new BitmapWorkerResult(new NullPointerException("Input Uri cannot be null")); } try { processInputUri(); } catch (NullPointerException | IOException e) { return new BitmapWorkerResult(e); } final ParcelFileDescriptor parcelFileDescriptor; try { parcelFileDescriptor = mContext.getContentResolver().openFileDescriptor(mInputUri, "r"); } catch (FileNotFoundException e) { return new BitmapWorkerResult(e); } final FileDescriptor fileDescriptor; if (parcelFileDescriptor != null) { fileDescriptor = parcelFileDescriptor.getFileDescriptor(); } else { return new BitmapWorkerResult(new NullPointerException("ParcelFileDescriptor was null for given Uri: [" + mInputUri + "]")); } final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options); if (options.outWidth == -1 || options.outHeight == -1) { return new BitmapWorkerResult(new IllegalArgumentException("Bounds for bitmap could not be retrieved from the Uri: [" + mInputUri + "]")); } options.inSampleSize = BitmapLoadUtils.calculateInSampleSize(options, mRequiredWidth, mRequiredHeight); options.inJustDecodeBounds = false; Bitmap decodeSampledBitmap = null; boolean decodeAttemptSuccess = false; while (!decodeAttemptSuccess) { try { decodeSampledBitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options); decodeAttemptSuccess = true; } catch (OutOfMemoryError error) { Log.e(TAG, "doInBackground: BitmapFactory.decodeFileDescriptor: ", error); options.inSampleSize *= 2; } } if (decodeSampledBitmap == null) { return new BitmapWorkerResult(new IllegalArgumentException("Bitmap could not be decoded from the Uri: [" + mInputUri + "]")); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { BitmapLoadUtils.close(parcelFileDescriptor); } int exifOrientation = BitmapLoadUtils.getExifOrientation(mContext, mInputUri); int exifDegrees = BitmapLoadUtils.exifToDegrees(exifOrientation); int exifTranslation = BitmapLoadUtils.exifToTranslation(exifOrientation); ExifInfo exifInfo = new ExifInfo(exifOrientation, exifDegrees, exifTranslation); Matrix matrix = new Matrix(); if (exifDegrees != 0) { matrix.preRotate(exifDegrees); } if (exifTranslation != 1) { matrix.postScale(exifTranslation, 1); } if (!matrix.isIdentity()) { return new BitmapWorkerResult(BitmapLoadUtils.transformBitmap(decodeSampledBitmap, matrix), exifInfo); } return new BitmapWorkerResult(decodeSampledBitmap, exifInfo); } private void processInputUri() throws NullPointerException, IOException { String inputUriScheme = mInputUri.toString(); Log.d(TAG, "Uri scheme: " + inputUriScheme); if (inputUriScheme.startsWith("http") || inputUriScheme.startsWith("https")) { try { downloadFile(mInputUri, mOutputUri); } catch (NullPointerException | IOException e) { Log.e(TAG, "Downloading failed", e); throw e; } } else { String path = getFilePath(); if (!TextUtils.isEmpty(path) && new File(path).exists()) { mInputUri = VersionUtils.isAndroidQ() ? mInputUri : Uri.fromFile(new File(path)); } else { try { copyFile(mInputUri, mOutputUri); } catch (NullPointerException | IOException e) { Log.e(TAG, "Copying failed", e); throw e; } } } } private String getFilePath() { if (ContextCompat.checkSelfPermission(mContext, permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { return FileUtils.getPath(mContext, mInputUri); } else { return null; } } private void copyFile(@NonNull Uri inputUri, @Nullable Uri outputUri) throws NullPointerException, IOException { Log.d(TAG, "copyFile"); if (outputUri == null) { throw new NullPointerException("Output Uri is null - cannot copy image"); } InputStream inputStream = null; OutputStream outputStream = null; try { inputStream = mContext.getContentResolver().openInputStream(inputUri); outputStream = new FileOutputStream(new File(outputUri.getPath())); if (inputStream == null) { throw new NullPointerException("InputStream for given input Uri is null"); } byte buffer[] = new byte[1024]; int length; while ((length = inputStream.read(buffer)) > 0) { outputStream.write(buffer, 0, length); } } finally { BitmapLoadUtils.close(outputStream); BitmapLoadUtils.close(inputStream); // swap uris, because input image was copied to the output destination // (cropped image will override it later) mInputUri = mOutputUri; } } private void downloadFile(@NonNull Uri inputUri, @Nullable Uri outputUri) throws NullPointerException, IOException { Log.d(TAG, "downloadFile"); if (outputUri == null) { throw new NullPointerException("Output Uri is null - cannot download image"); } try { URL u = new URL(inputUri.toString()); byte[] buffer = new byte[1024]; int read; BufferedInputStream bin; bin = new BufferedInputStream(u.openStream()); OutputStream outputStream = mContext.getContentResolver().openOutputStream(outputUri); BufferedOutputStream bout = new BufferedOutputStream( outputStream); while ((read = bin.read(buffer)) > -1) { bout.write(buffer, 0, read); } bout.flush(); bout.close(); bin.close(); outputStream.close(); } catch (Exception e) { e.printStackTrace(); } finally { } mInputUri = mOutputUri; } // private void downloadFile(@NonNull Uri inputUri, @Nullable Uri outputUri) throws NullPointerException, IOException { // Log.d(TAG, "downloadFile"); // // if (outputUri == null) { // throw new NullPointerException("Output Uri is null - cannot download image"); // } // // OkHttpClient client = new OkHttpClient(); // // BufferedSource source = null; // Sink sink = null; // Response response = null; // try { // Request request = new Request.Builder() // .url(inputUri.toString()) // .build(); // response = client.newCall(request).execute(); // source = response.body().source(); // // OutputStream outputStream = mContext.getContentResolver().openOutputStream(outputUri); // if (outputStream != null) { // sink = Okio.sink(outputStream); // source.readAll(sink); // } else { // throw new NullPointerException("OutputStream for given output Uri is null"); // } // } finally { // BitmapLoadUtils.close(source); // BitmapLoadUtils.close(sink); // if (response != null) { // BitmapLoadUtils.close(response.body()); // } // client.dispatcher().cancelAll(); // // // swap uris, because input image was downloaded to the output destination // // (cropped image will override it later) // mInputUri = mOutputUri; // } // } @Override protected void onPostExecute(@NonNull BitmapWorkerResult result) { if (result.mBitmapWorkerException == null) { mBitmapLoadCallback.onBitmapLoaded(result.mBitmapResult, result.mExifInfo, mInputUri, (mOutputUri == null) ? null : mOutputUri); } else { mBitmapLoadCallback.onFailure(result.mBitmapWorkerException); } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/BitmapLoadUtils.java ================================================ package com.matisse.ucrop.util; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Point; import android.media.ExifInterface; import android.net.Uri; import android.os.Build; import android.util.Log; import android.view.Display; import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.matisse.ucrop.callback.BitmapLoadCallback; import com.matisse.ucrop.callback.BitmapLoadShowCallback; import com.matisse.ucrop.task.BitmapLoadShowTask; import com.matisse.ucrop.task.BitmapLoadTask; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; /** * Created by Oleksii Shliama (https://github.com/shliama). */ public class BitmapLoadUtils { private static final String TAG = "BitmapLoadUtils"; public static void decodeBitmapInBackground(@NonNull Context context, @NonNull Uri uri, @Nullable Uri outputUri, int requiredWidth, int requiredHeight, BitmapLoadCallback loadCallback) { new BitmapLoadTask(context, uri, outputUri, requiredWidth, requiredHeight, loadCallback).execute(); } public static void decodeBitmapInBackground(@NonNull Context context, @NonNull Uri uri, int requiredWidth, int requiredHeight, BitmapLoadShowCallback loadCallback) { new BitmapLoadShowTask(context, uri, requiredWidth, requiredHeight, loadCallback).execute(); } public static Bitmap transformBitmap(@NonNull Bitmap bitmap, @NonNull Matrix transformMatrix) { try { Bitmap converted = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), transformMatrix, true); if (!bitmap.sameAs(converted)) { bitmap = converted; } } catch (OutOfMemoryError error) { Log.e(TAG, "transformBitmap: ", error); } return bitmap; } public static int calculateInSampleSize(@NonNull BitmapFactory.Options options, int reqWidth, int reqHeight) { // Raw height and width of image final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { // Calculate the largest inSampleSize value that is a power of 2 and keeps both // height and width lower or equal to the requested height and width. while ((height / inSampleSize) > reqHeight || (width / inSampleSize) > reqWidth) { inSampleSize *= 2; } } return inSampleSize; } public static int getExifOrientation(@NonNull Context context, @NonNull Uri imageUri) { int orientation = ExifInterface.ORIENTATION_UNDEFINED; try { InputStream stream = context.getContentResolver().openInputStream(imageUri); if (stream == null) { return orientation; } orientation = new ImageHeaderParser(stream).getOrientation(); close(stream); } catch (IOException e) { Log.e(TAG, "getExifOrientation: " + imageUri.toString(), e); } return orientation; } public static int exifToDegrees(int exifOrientation) { int rotation; switch (exifOrientation) { case ExifInterface.ORIENTATION_ROTATE_90: case ExifInterface.ORIENTATION_TRANSPOSE: rotation = 90; break; case ExifInterface.ORIENTATION_ROTATE_180: case ExifInterface.ORIENTATION_FLIP_VERTICAL: rotation = 180; break; case ExifInterface.ORIENTATION_ROTATE_270: case ExifInterface.ORIENTATION_TRANSVERSE: rotation = 270; break; default: rotation = 0; } return rotation; } public static int exifToTranslation(int exifOrientation) { int translation; switch (exifOrientation) { case ExifInterface.ORIENTATION_FLIP_HORIZONTAL: case ExifInterface.ORIENTATION_FLIP_VERTICAL: case ExifInterface.ORIENTATION_TRANSPOSE: case ExifInterface.ORIENTATION_TRANSVERSE: translation = -1; break; default: translation = 1; } return translation; } /** * This method calculates maximum size of both width and height of bitmap. * It is twice the device screen diagonal for default implementation (extra quality to zoom image). * Size cannot exceed max texture size. * * @return - max bitmap size in pixels. */ @SuppressWarnings({"SuspiciousNameCombination", "deprecation"}) public static int calculateMaxBitmapSize(@NonNull Context context) { WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); Display display = wm.getDefaultDisplay(); Point size = new Point(); int width, height; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) { display.getSize(size); width = size.x; height = size.y; } else { width = display.getWidth(); height = display.getHeight(); } // Twice the device screen diagonal as default int maxBitmapSize = (int) Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); // Check for max texture size via Canvas Canvas canvas = new Canvas(); final int maxCanvasSize = Math.min(canvas.getMaximumBitmapWidth(), canvas.getMaximumBitmapHeight()); if (maxCanvasSize > 0) { maxBitmapSize = Math.min(maxBitmapSize, maxCanvasSize); } // Check for max texture size via GL final int maxTextureSize = EglUtils.getMaxTextureSize(); if (maxTextureSize > 0) { maxBitmapSize = Math.min(maxBitmapSize, maxTextureSize); } Log.d(TAG, "maxBitmapSize: " + maxBitmapSize); return maxBitmapSize; } @SuppressWarnings("ConstantConditions") public static void close(@Nullable Closeable c) { if (c != null && c instanceof Closeable) { // java.lang.IncompatibleClassChangeError: interface not implemented try { c.close(); } catch (IOException e) { // silence } } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/CubicEasing.java ================================================ package com.matisse.ucrop.util; public final class CubicEasing { public static float easeOut(float time, float start, float end, float duration) { return end * ((time = time / duration - 1.0f) * time * time + 1.0f) + start; } public static float easeIn(float time, float start, float end, float duration) { return end * (time /= duration) * time * time + start; } public static float easeInOut(float time, float start, float end, float duration) { return (time /= duration / 2.0f) < 1.0f ? end / 2.0f * time * time * time + start : end / 2.0f * ((time -= 2.0f) * time * time + 2.0f) + start; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/EglUtils.java ================================================ package com.matisse.ucrop.util; import android.annotation.TargetApi; import android.opengl.EGL14; import android.opengl.EGLConfig; import android.opengl.EGLContext; import android.opengl.EGLDisplay; import android.opengl.EGLSurface; import android.opengl.GLES10; import android.opengl.GLES20; import android.os.Build; import android.util.Log; import javax.microedition.khronos.egl.EGL10; /** * Created by Oleksii Shliama [https://github.com/shliama] on 9/8/16. */ public class EglUtils { private static final String TAG = "EglUtils"; private EglUtils() { } public static int getMaxTextureSize() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { return getMaxTextureEgl14(); } else { return getMaxTextureEgl10(); } } catch (Exception e) { Log.d(TAG, "getMaxTextureSize: ", e); return 0; } } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) private static int getMaxTextureEgl14() { EGLDisplay dpy = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); int[] vers = new int[2]; EGL14.eglInitialize(dpy, vers, 0, vers, 1); int[] configAttr = { EGL14.EGL_COLOR_BUFFER_TYPE, EGL14.EGL_RGB_BUFFER, EGL14.EGL_LEVEL, 0, EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL14.EGL_SURFACE_TYPE, EGL14.EGL_PBUFFER_BIT, EGL14.EGL_NONE }; EGLConfig[] configs = new EGLConfig[1]; int[] numConfig = new int[1]; EGL14.eglChooseConfig(dpy, configAttr, 0, configs, 0, 1, numConfig, 0); if (numConfig[0] == 0) { return 0; } EGLConfig config = configs[0]; int[] surfAttr = { EGL14.EGL_WIDTH, 64, EGL14.EGL_HEIGHT, 64, EGL14.EGL_NONE }; EGLSurface surf = EGL14.eglCreatePbufferSurface(dpy, config, surfAttr, 0); int[] ctxAttrib = { EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE }; EGLContext ctx = EGL14.eglCreateContext(dpy, config, EGL14.EGL_NO_CONTEXT, ctxAttrib, 0); EGL14.eglMakeCurrent(dpy, surf, surf, ctx); int[] maxSize = new int[1]; GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, maxSize, 0); EGL14.eglMakeCurrent(dpy, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); EGL14.eglDestroySurface(dpy, surf); EGL14.eglDestroyContext(dpy, ctx); EGL14.eglTerminate(dpy); return maxSize[0]; } @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private static int getMaxTextureEgl10() { EGL10 egl = (EGL10) javax.microedition.khronos.egl.EGLContext.getEGL(); javax.microedition.khronos.egl.EGLDisplay dpy = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); int[] vers = new int[2]; egl.eglInitialize(dpy, vers); int[] configAttr = { EGL10.EGL_COLOR_BUFFER_TYPE, EGL10.EGL_RGB_BUFFER, EGL10.EGL_LEVEL, 0, EGL10.EGL_SURFACE_TYPE, EGL10.EGL_PBUFFER_BIT, EGL10.EGL_NONE }; javax.microedition.khronos.egl.EGLConfig[] configs = new javax.microedition.khronos.egl.EGLConfig[1]; int[] numConfig = new int[1]; egl.eglChooseConfig(dpy, configAttr, configs, 1, numConfig); if (numConfig[0] == 0) { return 0; } javax.microedition.khronos.egl.EGLConfig config = configs[0]; int[] surfAttr = { EGL10.EGL_WIDTH, 64, EGL10.EGL_HEIGHT, 64, EGL10.EGL_NONE }; javax.microedition.khronos.egl.EGLSurface surf = egl.eglCreatePbufferSurface(dpy, config, surfAttr); final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; // missing in EGL10 int[] ctxAttrib = { EGL_CONTEXT_CLIENT_VERSION, 1, EGL10.EGL_NONE }; javax.microedition.khronos.egl.EGLContext ctx = egl.eglCreateContext(dpy, config, EGL10.EGL_NO_CONTEXT, ctxAttrib); egl.eglMakeCurrent(dpy, surf, surf, ctx); int[] maxSize = new int[1]; GLES10.glGetIntegerv(GLES10.GL_MAX_TEXTURE_SIZE, maxSize, 0); egl.eglMakeCurrent(dpy, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT); egl.eglDestroySurface(dpy, surf); egl.eglDestroyContext(dpy, ctx); egl.eglTerminate(dpy); return maxSize[0]; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/FastBitmapDrawable.java ================================================ /* * Copyright (C) 2008 The Android Open Source Project * * 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 com.matisse.ucrop.util; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.PixelFormat; import android.graphics.drawable.Drawable; public class FastBitmapDrawable extends Drawable { private final Paint mPaint = new Paint(Paint.FILTER_BITMAP_FLAG); private Bitmap mBitmap; private int mAlpha; private int mWidth, mHeight; public FastBitmapDrawable(Bitmap b) { mAlpha = 255; setBitmap(b); } @Override public void draw(Canvas canvas) { if (mBitmap != null && !mBitmap.isRecycled()) { canvas.drawBitmap(mBitmap, null, getBounds(), mPaint); } } @Override public void setColorFilter(ColorFilter cf) { mPaint.setColorFilter(cf); } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } public void setFilterBitmap(boolean filterBitmap) { mPaint.setFilterBitmap(filterBitmap); } public int getAlpha() { return mAlpha; } @Override public void setAlpha(int alpha) { mAlpha = alpha; mPaint.setAlpha(alpha); } @Override public int getIntrinsicWidth() { return mWidth; } @Override public int getIntrinsicHeight() { return mHeight; } @Override public int getMinimumWidth() { return mWidth; } @Override public int getMinimumHeight() { return mHeight; } public Bitmap getBitmap() { return mBitmap; } public void setBitmap(Bitmap b) { mBitmap = b; if (b != null) { mWidth = mBitmap.getWidth(); mHeight = mBitmap.getHeight(); } else { mWidth = mHeight = 0; } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/FileUtils.java ================================================ /* * Copyright (C) 2007-2008 OpenIntents.org * * 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 com.matisse.ucrop.util; import android.annotation.SuppressLint; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.channels.FileChannel; import java.text.SimpleDateFormat; import java.util.Locale; /** * @author Peli * @author paulburke (ipaulpro) * @version 2013-12-11 */ public class FileUtils { private static SimpleDateFormat sf = new SimpleDateFormat("yyyyMMdd_HHmmssSS"); /** * TAG for log messages. */ static final String TAG = "FileUtils"; private FileUtils() { } /** * @param uri The Uri to check. * @return Whether the Uri authority is ExternalStorageProvider. * @author paulburke */ public static boolean isExternalStorageDocument(Uri uri) { return "com.android.externalstorage.documents".equals(uri.getAuthority()); } /** * @param uri The Uri to check. * @return Whether the Uri authority is DownloadsProvider. * @author paulburke */ public static boolean isDownloadsDocument(Uri uri) { return "com.android.providers.downloads.documents".equals(uri.getAuthority()); } /** * @param uri The Uri to check. * @return Whether the Uri authority is MediaProvider. * @author paulburke */ public static boolean isMediaDocument(Uri uri) { return "com.android.providers.media.documents".equals(uri.getAuthority()); } /** * @param uri The Uri to check. * @return Whether the Uri authority is Google Photos. */ public static boolean isGooglePhotosUri(Uri uri) { return "com.google.android.apps.photos.content".equals(uri.getAuthority()); } /** * Get the value of the data column for this Uri. This is useful for * MediaStore Uris, and other file-based ContentProviders. * * @param context The context. * @param uri The Uri to query. * @param selection (Optional) Filter used in the query. * @param selectionArgs (Optional) Selection arguments used in the query. * @return The value of the _data column, which is typically a file path. * @author paulburke */ public static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { Cursor cursor = null; final String column = "_data"; final String[] projection = { column }; try { cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null); if (cursor != null && cursor.moveToFirst()) { final int column_index = cursor.getColumnIndexOrThrow(column); return cursor.getString(column_index); } } catch (IllegalArgumentException ex) { Log.i(TAG, String.format(Locale.getDefault(), "getDataColumn: _data - [%s]", ex.getMessage())); } finally { if (cursor != null) { cursor.close(); } } return null; } /** * Get a file path from a Uri. This will get the the path for Storage Access * Framework Documents, as well as the _data field for the MediaStore and * other file-based ContentProviders.
*
* Callers should check whether the path is local before assuming it * represents a local file. * * @param context The context. * @param uri The Uri to query. * @author paulburke */ @SuppressLint("NewApi") public static String getPath(final Context context, final Uri uri) { final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; // DocumentProvider if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { if (isExternalStorageDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); final String[] split = docId.split(":"); final String type = split[0]; if ("primary".equalsIgnoreCase(type)) { return context .getExternalFilesDir(Environment.DIRECTORY_PICTURES) + "/" + split[1]; } // TODO handle non-primary volumes } // DownloadsProvider else if (isDownloadsDocument(uri)) { final String id = DocumentsContract.getDocumentId(uri); final Uri contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); return getDataColumn(context, contentUri, null, null); } // MediaProvider else if (isMediaDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); final String[] split = docId.split(":"); final String type = split[0]; Uri contentUri = null; if ("image".equals(type)) { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; } else if ("video".equals(type)) { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; } else if ("audio".equals(type)) { contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; } final String selection = "_id=?"; final String[] selectionArgs = new String[]{ split[1] }; return getDataColumn(context, contentUri, selection, selectionArgs); } } // MediaStore (and general) else if ("content".equalsIgnoreCase(uri.getScheme())) { // Return the remote address if (isGooglePhotosUri(uri)) { return uri.getLastPathSegment(); } return getDataColumn(context, uri, null, null); } // File else if ("file".equalsIgnoreCase(uri.getScheme())) { return uri.getPath(); } return null; } /** * Copies one file into the other with the given paths. * In the event that the paths are the same, trying to copy one file to the other * will cause both files to become null. * Simply skipping this step if the paths are identical. */ public static void copyFile(@NonNull String pathFrom, @NonNull String pathTo) throws IOException { if (pathFrom.equalsIgnoreCase(pathTo)) { return; } FileChannel outputChannel = null; FileChannel inputChannel = null; try { inputChannel = new FileInputStream(new File(pathFrom)).getChannel(); outputChannel = new FileOutputStream(new File(pathTo)).getChannel(); inputChannel.transferTo(0, inputChannel.size(), outputChannel); inputChannel.close(); } finally { if (inputChannel != null) inputChannel.close(); if (outputChannel != null) outputChannel.close(); } } public static boolean isGifForSuffix(String suffix) { return suffix != null && suffix.startsWith(".gif") || suffix.startsWith(".GIF"); } /** * 是否是gif * * @param mimeType * @return */ public static boolean isGif(String mimeType) { return mimeType != null && (mimeType.equals("image/gif") || mimeType.equals("image/GIF")); } /** * 是否是网络图片 * * @param path * @return */ public static boolean isHttp(String path) { if (!TextUtils.isEmpty(path)) { if (path.startsWith("http") || path.startsWith("https")) { return true; } } return false; } /** * Copies one file into the other with the given paths. * In the event that the paths are the same, trying to copy one file to the other * will cause both files to become null. * Simply skipping this step if the paths are identical. */ public static boolean copyFile(FileInputStream fileInputStream, String outFilePath) throws IOException { if (fileInputStream == null) { return false; } FileChannel inputChannel = null; FileChannel outputChannel = null; try { inputChannel = fileInputStream.getChannel(); outputChannel = new FileOutputStream(new File(outFilePath)).getChannel(); inputChannel.transferTo(0, inputChannel.size(), outputChannel); inputChannel.close(); return true; } catch (Exception e) { return false; } finally { if (inputChannel != null) inputChannel.close(); if (outputChannel != null) outputChannel.close(); } } public static String extSuffix(InputStream input) { try { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeStream(input, null, options); return options.outMimeType.replace("image/", "."); } catch (Exception e) { return ".jpg"; } } /** * 根据时间戳创建文件名 * * @param prefix 前缀名 * @return */ public static String getCreateFileName(String prefix) { long millis = System.currentTimeMillis(); return prefix + sf.format(millis); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/ImageHeaderParser.java ================================================ /* * Copyright 2015 Google, Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are * permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, this list of * conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright notice, this list * of conditions and the following disclaimer in the documentation and/or other materials * provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY GOOGLE, INC. ``AS IS'' AND ANY EXPRESS OR IMPLIED * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GOOGLE, INC. OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * The views and conclusions contained in the software and documentation are those of the * authors and should not be interpreted as representing official policies, either expressed * or implied, of Google, Inc. * * Adapted for the uCrop library. */ package com.matisse.ucrop.util; import android.media.ExifInterface; import android.text.TextUtils; import android.util.Log; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.Charset; /** * A class for parsing the exif orientation from an image header. */ public class ImageHeaderParser { private static final String TAG = "ImageHeaderParser"; /** * A constant indicating we were unable to parse the orientation from the image either because * no exif segment containing orientation data existed, or because of an I/O error attempting to * read the exif segment. */ public static final int UNKNOWN_ORIENTATION = -1; private static final int EXIF_MAGIC_NUMBER = 0xFFD8; // "MM". private static final int MOTOROLA_TIFF_MAGIC_NUMBER = 0x4D4D; // "II". private static final int INTEL_TIFF_MAGIC_NUMBER = 0x4949; private static final String JPEG_EXIF_SEGMENT_PREAMBLE = "Exif\0\0"; private static final byte[] JPEG_EXIF_SEGMENT_PREAMBLE_BYTES = JPEG_EXIF_SEGMENT_PREAMBLE.getBytes(Charset.forName("UTF-8")); private static final int SEGMENT_SOS = 0xDA; private static final int MARKER_EOI = 0xD9; private static final int SEGMENT_START_ID = 0xFF; private static final int EXIF_SEGMENT_TYPE = 0xE1; private static final int ORIENTATION_TAG_TYPE = 0x0112; private static final int[] BYTES_PER_FORMAT = {0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8}; private final Reader reader; public ImageHeaderParser(InputStream is) { reader = new StreamReader(is); } /** * Parse the orientation from the image header. If it doesn't handle this image type (or this is * not an image) it will return a default value rather than throwing an exception. * * @return The exif orientation if present or -1 if the header couldn't be parsed or doesn't * contain an orientation * @throws IOException */ public int getOrientation() throws IOException { final int magicNumber = reader.getUInt16(); if (!handles(magicNumber)) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Parser doesn't handle magic number: " + magicNumber); } return UNKNOWN_ORIENTATION; } else { int exifSegmentLength = moveToExifSegmentAndGetLength(); if (exifSegmentLength == -1) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Failed to parse exif segment length, or exif segment not found"); } return UNKNOWN_ORIENTATION; } byte[] exifData = new byte[exifSegmentLength]; return parseExifSegment(exifData, exifSegmentLength); } } private int parseExifSegment(byte[] tempArray, int exifSegmentLength) throws IOException { int read = reader.read(tempArray, exifSegmentLength); if (read != exifSegmentLength) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Unable to read exif segment data" + ", length: " + exifSegmentLength + ", actually read: " + read); } return UNKNOWN_ORIENTATION; } boolean hasJpegExifPreamble = hasJpegExifPreamble(tempArray, exifSegmentLength); if (hasJpegExifPreamble) { return parseExifSegment(new RandomAccessReader(tempArray, exifSegmentLength)); } else { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Missing jpeg exif preamble"); } return UNKNOWN_ORIENTATION; } } private boolean hasJpegExifPreamble(byte[] exifData, int exifSegmentLength) { boolean result = exifData != null && exifSegmentLength > JPEG_EXIF_SEGMENT_PREAMBLE_BYTES.length; if (result) { for (int i = 0; i < JPEG_EXIF_SEGMENT_PREAMBLE_BYTES.length; i++) { if (exifData[i] != JPEG_EXIF_SEGMENT_PREAMBLE_BYTES[i]) { result = false; break; } } } return result; } /** * Moves reader to the start of the exif segment and returns the length of the exif segment or * {@code -1} if no exif segment is found. */ private int moveToExifSegmentAndGetLength() throws IOException { short segmentId, segmentType; int segmentLength; while (true) { segmentId = reader.getUInt8(); if (segmentId != SEGMENT_START_ID) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Unknown segmentId=" + segmentId); } return -1; } segmentType = reader.getUInt8(); if (segmentType == SEGMENT_SOS) { return -1; } else if (segmentType == MARKER_EOI) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Found MARKER_EOI in exif segment"); } return -1; } // Segment length includes bytes for segment length. segmentLength = reader.getUInt16() - 2; if (segmentType != EXIF_SEGMENT_TYPE) { long skipped = reader.skip(segmentLength); if (skipped != segmentLength) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Unable to skip enough data" + ", type: " + segmentType + ", wanted to skip: " + segmentLength + ", but actually skipped: " + skipped); } return -1; } } else { return segmentLength; } } } private static int parseExifSegment(RandomAccessReader segmentData) { final int headerOffsetSize = JPEG_EXIF_SEGMENT_PREAMBLE.length(); short byteOrderIdentifier = segmentData.getInt16(headerOffsetSize); final ByteOrder byteOrder; if (byteOrderIdentifier == MOTOROLA_TIFF_MAGIC_NUMBER) { byteOrder = ByteOrder.BIG_ENDIAN; } else if (byteOrderIdentifier == INTEL_TIFF_MAGIC_NUMBER) { byteOrder = ByteOrder.LITTLE_ENDIAN; } else { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Unknown endianness = " + byteOrderIdentifier); } byteOrder = ByteOrder.BIG_ENDIAN; } segmentData.order(byteOrder); int firstIfdOffset = segmentData.getInt32(headerOffsetSize + 4) + headerOffsetSize; int tagCount = segmentData.getInt16(firstIfdOffset); int tagOffset, tagType, formatCode, componentCount; for (int i = 0; i < tagCount; i++) { tagOffset = calcTagOffset(firstIfdOffset, i); tagType = segmentData.getInt16(tagOffset); // We only want orientation. if (tagType != ORIENTATION_TAG_TYPE) { continue; } formatCode = segmentData.getInt16(tagOffset + 2); // 12 is max format code. if (formatCode < 1 || formatCode > 12) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Got invalid format code = " + formatCode); } continue; } componentCount = segmentData.getInt32(tagOffset + 4); if (componentCount < 0) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Negative tiff component count"); } continue; } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Got tagIndex=" + i + " tagType=" + tagType + " formatCode=" + formatCode + " componentCount=" + componentCount); } final int byteCount = componentCount + BYTES_PER_FORMAT[formatCode]; if (byteCount > 4) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Got byte count > 4, not orientation, continuing, formatCode=" + formatCode); } continue; } final int tagValueOffset = tagOffset + 8; if (tagValueOffset < 0 || tagValueOffset > segmentData.length()) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Illegal tagValueOffset=" + tagValueOffset + " tagType=" + tagType); } continue; } if (byteCount < 0 || tagValueOffset + byteCount > segmentData.length()) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Illegal number of bytes for TI tag data tagType=" + tagType); } continue; } //assume componentCount == 1 && fmtCode == 3 return segmentData.getInt16(tagValueOffset); } return -1; } private static int calcTagOffset(int ifdOffset, int tagIndex) { return ifdOffset + 2 + 12 * tagIndex; } private static boolean handles(int imageMagicNumber) { return (imageMagicNumber & EXIF_MAGIC_NUMBER) == EXIF_MAGIC_NUMBER || imageMagicNumber == MOTOROLA_TIFF_MAGIC_NUMBER || imageMagicNumber == INTEL_TIFF_MAGIC_NUMBER; } private static class RandomAccessReader { private final ByteBuffer data; public RandomAccessReader(byte[] data, int length) { this.data = (ByteBuffer) ByteBuffer.wrap(data) .order(ByteOrder.BIG_ENDIAN) .limit(length); } public void order(ByteOrder byteOrder) { this.data.order(byteOrder); } public int length() { return data.remaining(); } public int getInt32(int offset) { return data.getInt(offset); } public short getInt16(int offset) { return data.getShort(offset); } } private interface Reader { int getUInt16() throws IOException; short getUInt8() throws IOException; long skip(long total) throws IOException; int read(byte[] buffer, int byteCount) throws IOException; } private static class StreamReader implements Reader { private final InputStream is; // Motorola / big endian byte order. public StreamReader(InputStream is) { this.is = is; } @Override public int getUInt16() throws IOException { return (is.read() << 8 & 0xFF00) | (is.read() & 0xFF); } @Override public short getUInt8() throws IOException { return (short) (is.read() & 0xFF); } @Override public long skip(long total) throws IOException { if (total < 0) { return 0; } long toSkip = total; while (toSkip > 0) { long skipped = is.skip(toSkip); if (skipped > 0) { toSkip -= skipped; } else { // Skip has no specific contract as to what happens when you reach the end of // the stream. To differentiate between temporarily not having more data and // having finished the stream, we read a single byte when we fail to skip any // amount of data. int testEofByte = is.read(); if (testEofByte == -1) { break; } else { toSkip--; } } } return total - toSkip; } @Override public int read(byte[] buffer, int byteCount) throws IOException { int toRead = byteCount; int read; while (toRead > 0 && ((read = is.read(buffer, byteCount - toRead, toRead)) != -1)) { toRead -= read; } return byteCount - toRead; } } public static void copyExif(ExifInterface originalExif, int width, int height, String imageOutputPath) { String[] attributes = new String[]{ ExifInterface.TAG_APERTURE, ExifInterface.TAG_DATETIME, ExifInterface.TAG_DATETIME_DIGITIZED, ExifInterface.TAG_EXPOSURE_TIME, ExifInterface.TAG_FLASH, ExifInterface.TAG_FOCAL_LENGTH, ExifInterface.TAG_GPS_ALTITUDE, ExifInterface.TAG_GPS_ALTITUDE_REF, ExifInterface.TAG_GPS_DATESTAMP, ExifInterface.TAG_GPS_LATITUDE, ExifInterface.TAG_GPS_LATITUDE_REF, ExifInterface.TAG_GPS_LONGITUDE, ExifInterface.TAG_GPS_LONGITUDE_REF, ExifInterface.TAG_GPS_PROCESSING_METHOD, ExifInterface.TAG_GPS_TIMESTAMP, ExifInterface.TAG_ISO, ExifInterface.TAG_MAKE, ExifInterface.TAG_MODEL, ExifInterface.TAG_SUBSEC_TIME, ExifInterface.TAG_SUBSEC_TIME_DIG, ExifInterface.TAG_SUBSEC_TIME_ORIG, ExifInterface.TAG_WHITE_BALANCE }; try { ExifInterface newExif = new ExifInterface(imageOutputPath); String value; if (originalExif != null) { for (String attribute : attributes) { value = originalExif.getAttribute(attribute); if (!TextUtils.isEmpty(value)) { newExif.setAttribute(attribute, value); } } } newExif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH, String.valueOf(width)); newExif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH, String.valueOf(height)); newExif.setAttribute(ExifInterface.TAG_ORIENTATION, "0"); newExif.saveAttributes(); } catch (IOException e) { Log.d(TAG, e.getMessage()); } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/RectUtils.java ================================================ package com.matisse.ucrop.util; import android.graphics.RectF; public class RectUtils { /** * Gets a float array of the 2D coordinates representing a rectangles * corners. * The order of the corners in the float array is: * 0------->1 * ^ | * | | * | v * 3<-------2 * * @param r the rectangle to get the corners of * @return the float array of corners (8 floats) */ public static float[] getCornersFromRect(RectF r) { return new float[]{ r.left, r.top, r.right, r.top, r.right, r.bottom, r.left, r.bottom }; } /** * Gets a float array of two lengths representing a rectangles width and height * The order of the corners in the input float array is: * 0------->1 * ^ | * | | * | v * 3<-------2 * * @param corners the float array of corners (8 floats) * @return the float array of width and height (2 floats) */ public static float[] getRectSidesFromCorners(float[] corners) { return new float[]{(float) Math.sqrt(Math.pow(corners[0] - corners[2], 2) + Math.pow(corners[1] - corners[3], 2)), (float) Math.sqrt(Math.pow(corners[2] - corners[4], 2) + Math.pow(corners[3] - corners[5], 2))}; } public static float[] getCenterFromRect(RectF r) { return new float[]{r.centerX(), r.centerY()}; } /** * Takes an array of 2D coordinates representing corners and returns the * smallest rectangle containing those coordinates. * * @param array array of 2D coordinates * @return smallest rectangle containing coordinates */ public static RectF trapToRect(float[] array) { RectF r = new RectF(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY); for (int i = 1; i < array.length; i += 2) { float x = Math.round(array[i - 1] * 10) / 10.f; float y = Math.round(array[i] * 10) / 10.f; r.left = (x < r.left) ? x : r.left; r.top = (y < r.top) ? y : r.top; r.right = (x > r.right) ? x : r.right; r.bottom = (y > r.bottom) ? y : r.bottom; } r.sort(); return r; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/RotationGestureDetector.java ================================================ package com.matisse.ucrop.util; import android.view.MotionEvent; import androidx.annotation.NonNull; public class RotationGestureDetector { private static final int INVALID_POINTER_INDEX = -1; private float fX, fY, sX, sY; private int mPointerIndex1, mPointerIndex2; private float mAngle; private boolean mIsFirstTouch; private OnRotationGestureListener mListener; public RotationGestureDetector(OnRotationGestureListener listener) { mListener = listener; mPointerIndex1 = INVALID_POINTER_INDEX; mPointerIndex2 = INVALID_POINTER_INDEX; } public float getAngle() { return mAngle; } public boolean onTouchEvent(@NonNull MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: sX = event.getX(); sY = event.getY(); mPointerIndex1 = event.findPointerIndex(event.getPointerId(0)); mAngle = 0; mIsFirstTouch = true; break; case MotionEvent.ACTION_POINTER_DOWN: fX = event.getX(); fY = event.getY(); mPointerIndex2 = event.findPointerIndex(event.getPointerId(event.getActionIndex())); mAngle = 0; mIsFirstTouch = true; break; case MotionEvent.ACTION_MOVE: if (mPointerIndex1 != INVALID_POINTER_INDEX && mPointerIndex2 != INVALID_POINTER_INDEX && event.getPointerCount() > mPointerIndex2) { float nfX, nfY, nsX, nsY; nsX = event.getX(mPointerIndex1); nsY = event.getY(mPointerIndex1); nfX = event.getX(mPointerIndex2); nfY = event.getY(mPointerIndex2); if (mIsFirstTouch) { mAngle = 0; mIsFirstTouch = false; } else { calculateAngleBetweenLines(fX, fY, sX, sY, nfX, nfY, nsX, nsY); } if (mListener != null) { mListener.onRotation(this); } fX = nfX; fY = nfY; sX = nsX; sY = nsY; } break; case MotionEvent.ACTION_UP: mPointerIndex1 = INVALID_POINTER_INDEX; break; case MotionEvent.ACTION_POINTER_UP: mPointerIndex2 = INVALID_POINTER_INDEX; break; } return true; } private float calculateAngleBetweenLines(float fx1, float fy1, float fx2, float fy2, float sx1, float sy1, float sx2, float sy2) { return calculateAngleDelta( (float) Math.toDegrees((float) Math.atan2((fy1 - fy2), (fx1 - fx2))), (float) Math.toDegrees((float) Math.atan2((sy1 - sy2), (sx1 - sx2)))); } private float calculateAngleDelta(float angleFrom, float angleTo) { mAngle = angleTo % 360.0f - angleFrom % 360.0f; if (mAngle < -180.0f) { mAngle += 360.0f; } else if (mAngle > 180.0f) { mAngle -= 360.0f; } return mAngle; } public static class SimpleOnRotationGestureListener implements OnRotationGestureListener { @Override public boolean onRotation(RotationGestureDetector rotationDetector) { return false; } } public interface OnRotationGestureListener { boolean onRotation(RotationGestureDetector rotationDetector); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/SelectedStateListDrawable.java ================================================ package com.matisse.ucrop.util; import android.graphics.PorterDuff; import android.graphics.drawable.Drawable; import android.graphics.drawable.StateListDrawable; /** * Hack class to properly support state drawable back to Android 1.6 */ public class SelectedStateListDrawable extends StateListDrawable { private int mSelectionColor; public SelectedStateListDrawable(Drawable drawable, int selectionColor) { super(); this.mSelectionColor = selectionColor; addState(new int[]{android.R.attr.state_selected}, drawable); addState(new int[]{}, drawable); } @Override protected boolean onStateChange(int[] states) { boolean isStatePressedInArray = false; for (int state : states) { if (state == android.R.attr.state_selected) { isStatePressedInArray = true; } } if (isStatePressedInArray) { super.setColorFilter(mSelectionColor, PorterDuff.Mode.SRC_ATOP); } else { super.clearColorFilter(); } return super.onStateChange(states); } @Override public boolean isStateful() { return true; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/util/VersionUtils.java ================================================ package com.matisse.ucrop.util; import android.os.Build; /** * Created by Oleksii Shliama [https://github.com/shliama] on 9/8/16. */ public class VersionUtils { private VersionUtils() { } public static boolean isAndroidQ() { return Build.VERSION.SDK_INT >= 29; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/CropImageView.java ================================================ package com.matisse.ucrop.view; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.Matrix; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.matisse.R; import com.matisse.ucrop.callback.BitmapCropCallback; import com.matisse.ucrop.callback.CropBoundsChangeListener; import com.matisse.ucrop.model.CropParameters; import com.matisse.ucrop.model.ImageState; import com.matisse.ucrop.task.BitmapCropTask; import com.matisse.ucrop.util.CubicEasing; import com.matisse.ucrop.util.RectUtils; import java.lang.ref.WeakReference; import java.util.Arrays; /** * Created by Oleksii Shliama (https://github.com/shliama). *

* This class adds crop feature, methods to draw crop guidelines, and keep image in correct state. * Also it extends parent class methods to add checks for scale; animating zoom in/out. */ public class CropImageView extends TransformImageView { public static final int DEFAULT_MAX_BITMAP_SIZE = 0; public static final int DEFAULT_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION = 500; public static final float DEFAULT_MAX_SCALE_MULTIPLIER = 10.0f; public static final float SOURCE_IMAGE_ASPECT_RATIO = 0f; public static final float DEFAULT_ASPECT_RATIO = SOURCE_IMAGE_ASPECT_RATIO; private final RectF mCropRect = new RectF(); private final Matrix mTempMatrix = new Matrix(); private float mTargetAspectRatio; private float mMaxScaleMultiplier = DEFAULT_MAX_SCALE_MULTIPLIER; private CropBoundsChangeListener mCropBoundsChangeListener; private Runnable mWrapCropBoundsRunnable, mZoomImageToPositionRunnable = null; private float mMaxScale, mMinScale; private int mMaxResultImageSizeX = 0, mMaxResultImageSizeY = 0; private long mImageToWrapCropBoundsAnimDuration = DEFAULT_IMAGE_TO_CROP_BOUNDS_ANIM_DURATION; public CropImageView(Context context) { this(context, null); } public CropImageView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CropImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } /** * Cancels all current animations and sets image to fill crop area (without animation). * Then creates and executes {@link BitmapCropTask} with proper parameters. */ public void cropAndSaveImage(@NonNull Bitmap.CompressFormat compressFormat, int compressQuality, boolean mIsCircleCrop, @Nullable BitmapCropCallback cropCallback) { cancelAllAnimations(); setImageToWrapCropBounds(false); final ImageState imageState = new ImageState( mCropRect, RectUtils.trapToRect(mCurrentImageCorners), getCurrentScale(), getCurrentAngle()); final CropParameters cropParameters = new CropParameters( mMaxResultImageSizeX, mMaxResultImageSizeY, compressFormat, compressQuality, getImageInputUri(), getImageOutputPath(), getExifInfo()); new BitmapCropTask(getContext(), getViewBitmap(), imageState, cropParameters , mIsCircleCrop, cropCallback).execute(); } /** * @return - maximum scale value for current image and crop ratio */ public float getMaxScale() { return mMaxScale; } /** * @return - minimum scale value for current image and crop ratio */ public float getMinScale() { return mMinScale; } /** * @return - aspect ratio for crop bounds */ public float getTargetAspectRatio() { return mTargetAspectRatio; } /** * Updates current crop rectangle with given. Also recalculates image properties and position * to fit new crop rectangle. * * @param cropRect - new crop rectangle */ public void setCropRect(RectF cropRect) { mTargetAspectRatio = cropRect.width() / cropRect.height(); mCropRect.set(cropRect.left - getPaddingLeft(), cropRect.top - getPaddingTop(), cropRect.right - getPaddingRight(), cropRect.bottom - getPaddingBottom()); calculateImageScaleBounds(); setImageToWrapCropBounds(); } /** * This method sets aspect ratio for crop bounds. * If {@link #SOURCE_IMAGE_ASPECT_RATIO} value is passed - aspect ratio is calculated * based on current image width and height. * * @param targetAspectRatio - aspect ratio for image crop (e.g. 1.77(7) for 16:9) */ public void setTargetAspectRatio(float targetAspectRatio) { final Drawable drawable = getDrawable(); if (drawable == null) { mTargetAspectRatio = targetAspectRatio; return; } if (targetAspectRatio == SOURCE_IMAGE_ASPECT_RATIO) { mTargetAspectRatio = drawable.getIntrinsicWidth() / (float) drawable.getIntrinsicHeight(); } else { mTargetAspectRatio = targetAspectRatio; } if (mCropBoundsChangeListener != null) { mCropBoundsChangeListener.onCropAspectRatioChanged(mTargetAspectRatio); } } @Nullable public CropBoundsChangeListener getCropBoundsChangeListener() { return mCropBoundsChangeListener; } public void setCropBoundsChangeListener(@Nullable CropBoundsChangeListener cropBoundsChangeListener) { mCropBoundsChangeListener = cropBoundsChangeListener; } /** * This method sets maximum width for resulting cropped image * * @param maxResultImageSizeX - size in pixels */ public void setMaxResultImageSizeX(@IntRange(from = 10) int maxResultImageSizeX) { mMaxResultImageSizeX = maxResultImageSizeX; } /** * This method sets maximum width for resulting cropped image * * @param maxResultImageSizeY - size in pixels */ public void setMaxResultImageSizeY(@IntRange(from = 10) int maxResultImageSizeY) { mMaxResultImageSizeY = maxResultImageSizeY; } /** * This method sets animation duration for image to wrap the crop bounds * * @param imageToWrapCropBoundsAnimDuration - duration in milliseconds */ public void setImageToWrapCropBoundsAnimDuration(@IntRange(from = 100) long imageToWrapCropBoundsAnimDuration) { if (imageToWrapCropBoundsAnimDuration > 0) { mImageToWrapCropBoundsAnimDuration = imageToWrapCropBoundsAnimDuration; } else { throw new IllegalArgumentException("Animation duration cannot be negative value."); } } /** * This method sets multiplier that is used to calculate max image scale from min image scale. * * @param maxScaleMultiplier - (minScale * maxScaleMultiplier) = maxScale */ public void setMaxScaleMultiplier(float maxScaleMultiplier) { mMaxScaleMultiplier = maxScaleMultiplier; } /** * This method scales image down for given value related to image center. */ public void zoomOutImage(float deltaScale) { zoomOutImage(deltaScale, mCropRect.centerX(), mCropRect.centerY()); } /** * This method scales image down for given value related given coords (x, y). */ public void zoomOutImage(float scale, float centerX, float centerY) { if (scale >= getMinScale()) { postScale(scale / getCurrentScale(), centerX, centerY); } } /** * This method scales image up for given value related to image center. */ public void zoomInImage(float deltaScale) { zoomInImage(deltaScale, mCropRect.centerX(), mCropRect.centerY()); } /** * This method scales image up for given value related to given coords (x, y). */ public void zoomInImage(float scale, float centerX, float centerY) { if (scale <= getMaxScale()) { postScale(scale / getCurrentScale(), centerX, centerY); } } /** * This method changes image scale for given value related to point (px, py) but only if * resulting scale is in min/max bounds. * * @param deltaScale - scale value * @param px - scale center X * @param py - scale center Y */ public void postScale(float deltaScale, float px, float py) { if (deltaScale > 1 && getCurrentScale() * deltaScale <= getMaxScale()) { super.postScale(deltaScale, px, py); } else if (deltaScale < 1 && getCurrentScale() * deltaScale >= getMinScale()) { super.postScale(deltaScale, px, py); } } /** * This method rotates image for given angle related to the image center. * * @param deltaAngle - angle to rotate */ public void postRotate(float deltaAngle) { postRotate(deltaAngle, mCropRect.centerX(), mCropRect.centerY()); } /** * This method cancels all current Runnable objects that represent animations. */ public void cancelAllAnimations() { removeCallbacks(mWrapCropBoundsRunnable); removeCallbacks(mZoomImageToPositionRunnable); } public void setImageToWrapCropBounds() { setImageToWrapCropBounds(true); } /** * If image doesn't fill the crop bounds it must be translated and scaled properly to fill those. *

* Therefore this method calculates delta X, Y and scale values and passes them to the * {@link WrapCropBoundsRunnable} which animates image. * Scale value must be calculated only if image won't fill the crop bounds after it's translated to the * crop bounds rectangle center. Using temporary variables this method checks this case. */ public void setImageToWrapCropBounds(boolean animate) { if (mBitmapLaidOut && !isImageWrapCropBounds()) { float currentX = mCurrentImageCenter[0]; float currentY = mCurrentImageCenter[1]; float currentScale = getCurrentScale(); float deltaX = mCropRect.centerX() - currentX; float deltaY = mCropRect.centerY() - currentY; float deltaScale = 0; mTempMatrix.reset(); mTempMatrix.setTranslate(deltaX, deltaY); final float[] tempCurrentImageCorners = Arrays.copyOf(mCurrentImageCorners, mCurrentImageCorners.length); mTempMatrix.mapPoints(tempCurrentImageCorners); boolean willImageWrapCropBoundsAfterTranslate = isImageWrapCropBounds(tempCurrentImageCorners); if (willImageWrapCropBoundsAfterTranslate) { final float[] imageIndents = calculateImageIndents(); deltaX = -(imageIndents[0] + imageIndents[2]); deltaY = -(imageIndents[1] + imageIndents[3]); } else { RectF tempCropRect = new RectF(mCropRect); mTempMatrix.reset(); mTempMatrix.setRotate(getCurrentAngle()); mTempMatrix.mapRect(tempCropRect); final float[] currentImageSides = RectUtils.getRectSidesFromCorners(mCurrentImageCorners); deltaScale = Math.max(tempCropRect.width() / currentImageSides[0], tempCropRect.height() / currentImageSides[1]); deltaScale = deltaScale * currentScale - currentScale; } if (animate) { post(mWrapCropBoundsRunnable = new WrapCropBoundsRunnable( CropImageView.this, mImageToWrapCropBoundsAnimDuration, currentX, currentY, deltaX, deltaY, currentScale, deltaScale, willImageWrapCropBoundsAfterTranslate)); } else { postTranslate(deltaX, deltaY); if (!willImageWrapCropBoundsAfterTranslate) { zoomInImage(currentScale + deltaScale, mCropRect.centerX(), mCropRect.centerY()); } } } } /** * First, un-rotate image and crop rectangles (make image rectangle axis-aligned). * Second, calculate deltas between those rectangles sides. * Third, depending on delta (its sign) put them or zero inside an array. * Fourth, using Matrix, rotate back those points (indents). * * @return - the float array of image indents (4 floats) - in this order [left, top, right, bottom] */ private float[] calculateImageIndents() { mTempMatrix.reset(); mTempMatrix.setRotate(-getCurrentAngle()); float[] unrotatedImageCorners = Arrays.copyOf(mCurrentImageCorners, mCurrentImageCorners.length); float[] unrotatedCropBoundsCorners = RectUtils.getCornersFromRect(mCropRect); mTempMatrix.mapPoints(unrotatedImageCorners); mTempMatrix.mapPoints(unrotatedCropBoundsCorners); RectF unrotatedImageRect = RectUtils.trapToRect(unrotatedImageCorners); RectF unrotatedCropRect = RectUtils.trapToRect(unrotatedCropBoundsCorners); float deltaLeft = unrotatedImageRect.left - unrotatedCropRect.left; float deltaTop = unrotatedImageRect.top - unrotatedCropRect.top; float deltaRight = unrotatedImageRect.right - unrotatedCropRect.right; float deltaBottom = unrotatedImageRect.bottom - unrotatedCropRect.bottom; float indents[] = new float[4]; indents[0] = (deltaLeft > 0) ? deltaLeft : 0; indents[1] = (deltaTop > 0) ? deltaTop : 0; indents[2] = (deltaRight < 0) ? deltaRight : 0; indents[3] = (deltaBottom < 0) ? deltaBottom : 0; mTempMatrix.reset(); mTempMatrix.setRotate(getCurrentAngle()); mTempMatrix.mapPoints(indents); return indents; } /** * When image is laid out it must be centered properly to fit current crop bounds. */ @Override protected void onImageLaidOut() { super.onImageLaidOut(); final Drawable drawable = getDrawable(); if (drawable == null) { return; } float drawableWidth = drawable.getIntrinsicWidth(); float drawableHeight = drawable.getIntrinsicHeight(); if (mTargetAspectRatio == SOURCE_IMAGE_ASPECT_RATIO) { mTargetAspectRatio = drawableWidth / drawableHeight; } int height = (int) (mThisWidth / mTargetAspectRatio); if (height > mThisHeight) { int width = (int) (mThisHeight * mTargetAspectRatio); int halfDiff = (mThisWidth - width) / 2; mCropRect.set(halfDiff, 0, width + halfDiff, mThisHeight); } else { int halfDiff = (mThisHeight - height) / 2; mCropRect.set(0, halfDiff, mThisWidth, height + halfDiff); } calculateImageScaleBounds(drawableWidth, drawableHeight); setupInitialImagePosition(drawableWidth, drawableHeight); if (mCropBoundsChangeListener != null) { mCropBoundsChangeListener.onCropAspectRatioChanged(mTargetAspectRatio); } if (mTransformImageListener != null) { mTransformImageListener.onScale(getCurrentScale()); mTransformImageListener.onRotate(getCurrentAngle()); } } /** * This method checks whether current image fills the crop bounds. */ protected boolean isImageWrapCropBounds() { return isImageWrapCropBounds(mCurrentImageCorners); } /** * This methods checks whether a rectangle that is represented as 4 corner points (8 floats) * fills the crop bounds rectangle. * * @param imageCorners - corners of a rectangle * @return - true if it wraps crop bounds, false - otherwise */ protected boolean isImageWrapCropBounds(float[] imageCorners) { mTempMatrix.reset(); mTempMatrix.setRotate(-getCurrentAngle()); float[] unrotatedImageCorners = Arrays.copyOf(imageCorners, imageCorners.length); mTempMatrix.mapPoints(unrotatedImageCorners); float[] unrotatedCropBoundsCorners = RectUtils.getCornersFromRect(mCropRect); mTempMatrix.mapPoints(unrotatedCropBoundsCorners); return RectUtils.trapToRect(unrotatedImageCorners).contains(RectUtils.trapToRect(unrotatedCropBoundsCorners)); } /** * This method changes image scale (animating zoom for given duration), related to given center (x,y). * * @param scale - target scale * @param centerX - scale center X * @param centerY - scale center Y * @param durationMs - zoom animation duration */ protected void zoomImageToPosition(float scale, float centerX, float centerY, long durationMs) { if (scale > getMaxScale()) { scale = getMaxScale(); } final float oldScale = getCurrentScale(); final float deltaScale = scale - oldScale; post(mZoomImageToPositionRunnable = new ZoomImageToPosition(CropImageView.this, durationMs, oldScale, deltaScale, centerX, centerY)); } private void calculateImageScaleBounds() { final Drawable drawable = getDrawable(); if (drawable == null) { return; } calculateImageScaleBounds(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); } /** * This method calculates image minimum and maximum scale values for current {@link #mCropRect}. * * @param drawableWidth - image width * @param drawableHeight - image height */ private void calculateImageScaleBounds(float drawableWidth, float drawableHeight) { float widthScale = Math.min(mCropRect.width() / drawableWidth, mCropRect.width() / drawableHeight); float heightScale = Math.min(mCropRect.height() / drawableHeight, mCropRect.height() / drawableWidth); mMinScale = Math.min(widthScale, heightScale); mMaxScale = mMinScale * mMaxScaleMultiplier; } /** * This method calculates initial image position so it is positioned properly. * Then it sets those values to the current image matrix. * * @param drawableWidth - image width * @param drawableHeight - image height */ private void setupInitialImagePosition(float drawableWidth, float drawableHeight) { float cropRectWidth = mCropRect.width(); float cropRectHeight = mCropRect.height(); float widthScale = mCropRect.width() / drawableWidth; float heightScale = mCropRect.height() / drawableHeight; float initialMinScale = Math.max(widthScale, heightScale); float tw = (cropRectWidth - drawableWidth * initialMinScale) / 2.0f + mCropRect.left; float th = (cropRectHeight - drawableHeight * initialMinScale) / 2.0f + mCropRect.top; mCurrentImageMatrix.reset(); mCurrentImageMatrix.postScale(initialMinScale, initialMinScale); mCurrentImageMatrix.postTranslate(tw, th); setImageMatrix(mCurrentImageMatrix); } /** * This method extracts all needed values from the styled attributes. * Those are used to configure the view. */ @SuppressWarnings("deprecation") protected void processStyledAttributes(@NonNull TypedArray a) { float targetAspectRatioX = Math.abs(a.getFloat(R.styleable.ucrop_UCropView_ucrop_aspect_ratio_x, DEFAULT_ASPECT_RATIO)); float targetAspectRatioY = Math.abs(a.getFloat(R.styleable.ucrop_UCropView_ucrop_aspect_ratio_y, DEFAULT_ASPECT_RATIO)); if (targetAspectRatioX == SOURCE_IMAGE_ASPECT_RATIO || targetAspectRatioY == SOURCE_IMAGE_ASPECT_RATIO) { mTargetAspectRatio = SOURCE_IMAGE_ASPECT_RATIO; } else { mTargetAspectRatio = targetAspectRatioX / targetAspectRatioY; } } /** * This Runnable is used to animate an image so it fills the crop bounds entirely. * Given values are interpolated during the animation time. * Runnable can be terminated either vie {@link #cancelAllAnimations()} method * or when certain conditions inside {@link WrapCropBoundsRunnable#run()} method are triggered. */ private static class WrapCropBoundsRunnable implements Runnable { private final WeakReference mCropImageView; private final long mDurationMs, mStartTime; private final float mOldX, mOldY; private final float mCenterDiffX, mCenterDiffY; private final float mOldScale; private final float mDeltaScale; private final boolean mWillBeImageInBoundsAfterTranslate; public WrapCropBoundsRunnable(CropImageView cropImageView, long durationMs, float oldX, float oldY, float centerDiffX, float centerDiffY, float oldScale, float deltaScale, boolean willBeImageInBoundsAfterTranslate) { mCropImageView = new WeakReference<>(cropImageView); mDurationMs = durationMs; mStartTime = System.currentTimeMillis(); mOldX = oldX; mOldY = oldY; mCenterDiffX = centerDiffX; mCenterDiffY = centerDiffY; mOldScale = oldScale; mDeltaScale = deltaScale; mWillBeImageInBoundsAfterTranslate = willBeImageInBoundsAfterTranslate; } @Override public void run() { CropImageView cropImageView = mCropImageView.get(); if (cropImageView == null) { return; } long now = System.currentTimeMillis(); float currentMs = Math.min(mDurationMs, now - mStartTime); float newX = CubicEasing.easeOut(currentMs, 0, mCenterDiffX, mDurationMs); float newY = CubicEasing.easeOut(currentMs, 0, mCenterDiffY, mDurationMs); float newScale = CubicEasing.easeInOut(currentMs, 0, mDeltaScale, mDurationMs); if (currentMs < mDurationMs) { cropImageView.postTranslate(newX - (cropImageView.mCurrentImageCenter[0] - mOldX), newY - (cropImageView.mCurrentImageCenter[1] - mOldY)); if (!mWillBeImageInBoundsAfterTranslate) { cropImageView.zoomInImage(mOldScale + newScale, cropImageView.mCropRect.centerX(), cropImageView.mCropRect.centerY()); } if (!cropImageView.isImageWrapCropBounds()) { cropImageView.post(this); } } } } /** * This Runnable is used to animate an image zoom. * Given values are interpolated during the animation time. * Runnable can be terminated either vie {@link #cancelAllAnimations()} method * or when certain conditions inside {@link ZoomImageToPosition#run()} method are triggered. */ private static class ZoomImageToPosition implements Runnable { private final WeakReference mCropImageView; private final long mDurationMs, mStartTime; private final float mOldScale; private final float mDeltaScale; private final float mDestX; private final float mDestY; public ZoomImageToPosition(CropImageView cropImageView, long durationMs, float oldScale, float deltaScale, float destX, float destY) { mCropImageView = new WeakReference<>(cropImageView); mStartTime = System.currentTimeMillis(); mDurationMs = durationMs; mOldScale = oldScale; mDeltaScale = deltaScale; mDestX = destX; mDestY = destY; } @Override public void run() { CropImageView cropImageView = mCropImageView.get(); if (cropImageView == null) { return; } long now = System.currentTimeMillis(); float currentMs = Math.min(mDurationMs, now - mStartTime); float newScale = CubicEasing.easeInOut(currentMs, 0, mDeltaScale, mDurationMs); if (currentMs < mDurationMs) { cropImageView.zoomInImage(mOldScale + newScale, mDestX, mDestY); cropImageView.post(this); } else { cropImageView.setImageToWrapCropBounds(); } } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/GestureCropImageView.java ================================================ package com.matisse.ucrop.view; import android.content.Context; import android.util.AttributeSet; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import com.matisse.ucrop.util.RotationGestureDetector; /** * Created by Oleksii Shliama (https://github.com/shliama). */ public class GestureCropImageView extends CropImageView { private static final int DOUBLE_TAP_ZOOM_DURATION = 200; private ScaleGestureDetector mScaleDetector; private RotationGestureDetector mRotateDetector; private GestureDetector mGestureDetector; private float mMidPntX, mMidPntY; private boolean mIsRotateEnabled = true, mIsScaleEnabled = true; private int mDoubleTapScaleSteps = 5; public GestureCropImageView(Context context) { super(context); } public GestureCropImageView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public GestureCropImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public void setScaleEnabled(boolean scaleEnabled) { mIsScaleEnabled = scaleEnabled; } public boolean isScaleEnabled() { return mIsScaleEnabled; } public void setRotateEnabled(boolean rotateEnabled) { mIsRotateEnabled = rotateEnabled; } public boolean isRotateEnabled() { return mIsRotateEnabled; } public void setDoubleTapScaleSteps(int doubleTapScaleSteps) { mDoubleTapScaleSteps = doubleTapScaleSteps; } public int getDoubleTapScaleSteps() { return mDoubleTapScaleSteps; } /** * If it's ACTION_DOWN event - user touches the screen and all current animation must be canceled. * If it's ACTION_UP event - user removed all fingers from the screen and current image position must be corrected. * If there are more than 2 fingers - update focal point coordinates. * Pass the event to the gesture detectors if those are enabled. */ @Override public boolean onTouchEvent(MotionEvent event) { if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) { cancelAllAnimations(); } if (event.getPointerCount() > 1) { mMidPntX = (event.getX(0) + event.getX(1)) / 2; mMidPntY = (event.getY(0) + event.getY(1)) / 2; } mGestureDetector.onTouchEvent(event); if (mIsScaleEnabled) { mScaleDetector.onTouchEvent(event); } if (mIsRotateEnabled) { mRotateDetector.onTouchEvent(event); } if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP) { setImageToWrapCropBounds(); } return true; } @Override protected void init() { super.init(); setupGestureListeners(); } /** * This method calculates target scale value for double tap gesture. * User is able to zoom the image from min scale value * to the max scale value with {@link #mDoubleTapScaleSteps} double taps. */ protected float getDoubleTapTargetScale() { return getCurrentScale() * (float) Math.pow(getMaxScale() / getMinScale(), 1.0f / mDoubleTapScaleSteps); } private void setupGestureListeners() { mGestureDetector = new GestureDetector(getContext(), new GestureListener(), null, true); mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener()); mRotateDetector = new RotationGestureDetector(new RotateListener()); } private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { @Override public boolean onScale(ScaleGestureDetector detector) { postScale(detector.getScaleFactor(), mMidPntX, mMidPntY); return true; } } private class GestureListener extends GestureDetector.SimpleOnGestureListener { @Override public boolean onDoubleTap(MotionEvent e) { zoomImageToPosition(getDoubleTapTargetScale(), e.getX(), e.getY(), DOUBLE_TAP_ZOOM_DURATION); return super.onDoubleTap(e); } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { postTranslate(-distanceX, -distanceY); return true; } } private class RotateListener extends RotationGestureDetector.SimpleOnRotationGestureListener { @Override public boolean onRotation(RotationGestureDetector rotationDetector) { postRotate(rotationDetector.getAngle(), mMidPntX, mMidPntY); return true; } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/OverlayView.java ================================================ package com.matisse.ucrop.view; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.graphics.RectF; import android.graphics.Region; import android.os.Build; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import androidx.annotation.ColorInt; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import com.matisse.R; import com.matisse.ucrop.callback.OverlayViewChangeListener; import com.matisse.ucrop.util.RectUtils; /** * Created by Oleksii Shliama (https://github.com/shliama). *

* This view is used for drawing the overlay on top of the image. It may have frame, crop guidelines and dimmed area. * This must have LAYER_TYPE_SOFTWARE to draw itself properly. */ public class OverlayView extends View { public static final boolean DEFAULT_DRAG_FRAME = true; public static final boolean DEFAULT_SHOW_CROP_FRAME = true; public static final boolean DEFAULT_SHOW_CROP_GRID = true; public static final boolean DEFAULT_CIRCLE_DIMMED_LAYER = false; public static final boolean DEFAULT_FREESTYLE_CROP_ENABLED = false; public static final int DEFAULT_CROP_GRID_ROW_COUNT = 2; public static final int DEFAULT_CROP_GRID_COLUMN_COUNT = 2; private boolean mIsDragFrame = DEFAULT_DRAG_FRAME; private final RectF mCropViewRect = new RectF(); private final RectF mTempRect = new RectF(); private int mCropGridRowCount, mCropGridColumnCount; private float mTargetAspectRatio; private float[] mGridPoints = null; private boolean mShowCropFrame, mShowCropGrid; private boolean mCircleDimmedLayer; private int mDimmedColor; private Path mCircularPath = new Path(); private Paint mDimmedStrokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); private Paint mCropGridPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private Paint mCropFramePaint = new Paint(Paint.ANTI_ALIAS_FLAG); private Paint mCropFrameCornersPaint = new Paint(Paint.ANTI_ALIAS_FLAG); protected int mThisWidth, mThisHeight; private boolean mIsFreestyleCropEnabled = DEFAULT_FREESTYLE_CROP_ENABLED; protected float[] mCropGridCorners; protected float[] mCropGridCenter; private float mPreviousTouchX = -1, mPreviousTouchY = -1; private int mCurrentTouchCornerIndex = -1; private int mTouchPointThreshold; private int mCropRectMinSize; private int mCropRectCornerTouchAreaLineLength; private OverlayViewChangeListener mCallback; private boolean mShouldSetupCropBounds; { mTouchPointThreshold = getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_rect_corner_touch_threshold); mCropRectMinSize = getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_rect_min_size); mCropRectCornerTouchAreaLineLength = getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_rect_corner_touch_area_line_length); } public OverlayView(Context context) { this(context, null); } public OverlayView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public OverlayView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } public OverlayViewChangeListener getOverlayViewChangeListener() { return mCallback; } public void setOverlayViewChangeListener(OverlayViewChangeListener callback) { mCallback = callback; } @NonNull public RectF getCropViewRect() { return mCropViewRect; } public boolean isFreestyleCropEnabled() { return mIsFreestyleCropEnabled; } public void setFreestyleCropEnabled(boolean freestyleCropEnabled) { mIsFreestyleCropEnabled = freestyleCropEnabled; } public boolean ismIsDragFrame() { return mIsDragFrame; } public void setDragFrame(boolean mIsDragFrame) { this.mIsDragFrame = mIsDragFrame; } /** * Setter for {@link #mCircleDimmedLayer} variable. * * @param circleDimmedLayer - set it to true if you want dimmed layer to be an circle */ public void setCircleDimmedLayer(boolean circleDimmedLayer) { mCircleDimmedLayer = circleDimmedLayer; } /** * Setter for crop grid rows count. * Resets {@link #mGridPoints} variable because it is not valid anymore. */ public void setCropGridRowCount(@IntRange(from = 0) int cropGridRowCount) { mCropGridRowCount = cropGridRowCount; mGridPoints = null; } /** * Setter for crop grid columns count. * Resets {@link #mGridPoints} variable because it is not valid anymore. */ public void setCropGridColumnCount(@IntRange(from = 0) int cropGridColumnCount) { mCropGridColumnCount = cropGridColumnCount; mGridPoints = null; } /** * Setter for {@link #mShowCropFrame} variable. * * @param showCropFrame - set to true if you want to see a crop frame rectangle on top of an image */ public void setShowCropFrame(boolean showCropFrame) { mShowCropFrame = showCropFrame; } /** * Setter for {@link #mShowCropGrid} variable. * * @param showCropGrid - set to true if you want to see a crop grid on top of an image */ public void setShowCropGrid(boolean showCropGrid) { mShowCropGrid = showCropGrid; } /** * Setter for {@link #mDimmedColor} variable. * * @param dimmedColor - desired color of dimmed area around the crop bounds */ public void setDimmedColor(@ColorInt int dimmedColor) { mDimmedColor = dimmedColor; } /** * Setter for crop frame stroke width */ public void setCropFrameStrokeWidth(@IntRange(from = 0) int width) { mCropFramePaint.setStrokeWidth(width); } /** * Setter for crop grid stroke width */ public void setCropGridStrokeWidth(@IntRange(from = 0) int width) { mCropGridPaint.setStrokeWidth(width); } /** * Setter for crop frame color */ public void setCropFrameColor(@ColorInt int color) { mCropFramePaint.setColor(color); } /** * Setter for crop grid color */ public void setCropGridColor(@ColorInt int color) { mCropGridPaint.setColor(color); } /** * This method sets aspect ratio for crop bounds. * * @param targetAspectRatio - aspect ratio for image crop (e.g. 1.77(7) for 16:9) */ public void setTargetAspectRatio(final float targetAspectRatio) { mTargetAspectRatio = targetAspectRatio; if (mThisWidth > 0) { setupCropBounds(); postInvalidate(); } else { mShouldSetupCropBounds = true; } } /** * This method setups crop bounds rectangles for given aspect ratio and view size. * {@link #mCropViewRect} is used to draw crop bounds - uses padding. */ public void setupCropBounds() { int height = (int) (mThisWidth / mTargetAspectRatio); if (height > mThisHeight) { int width = (int) (mThisHeight * mTargetAspectRatio); int halfDiff = (mThisWidth - width) / 2; mCropViewRect.set(getPaddingLeft() + halfDiff, getPaddingTop(), getPaddingLeft() + width + halfDiff, getPaddingTop() + mThisHeight); } else { int halfDiff = (mThisHeight - height) / 2; mCropViewRect.set(getPaddingLeft(), getPaddingTop() + halfDiff, getPaddingLeft() + mThisWidth, getPaddingTop() + height + halfDiff); } if (mCallback != null) { mCallback.onCropRectUpdated(mCropViewRect); } updateGridPoints(); } private void updateGridPoints() { mCropGridCorners = RectUtils.getCornersFromRect(mCropViewRect); mCropGridCenter = RectUtils.getCenterFromRect(mCropViewRect); mGridPoints = null; mCircularPath.reset(); mCircularPath.addCircle(mCropViewRect.centerX(), mCropViewRect.centerY(), Math.min(mCropViewRect.width(), mCropViewRect.height()) / 2.f, Path.Direction.CW); } protected void init() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { setLayerType(LAYER_TYPE_SOFTWARE, null); } } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed) { left = getPaddingLeft(); top = getPaddingTop(); right = getWidth() - getPaddingRight(); bottom = getHeight() - getPaddingBottom(); mThisWidth = right - left; mThisHeight = bottom - top; if (mShouldSetupCropBounds) { mShouldSetupCropBounds = false; setTargetAspectRatio(mTargetAspectRatio); } } } /** * Along with image there are dimmed layer, crop bounds and crop guidelines that must be drawn. */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawDimmedLayer(canvas); drawCropGrid(canvas); } @Override public boolean onTouchEvent(MotionEvent event) { if (mCropViewRect.isEmpty() || !mIsFreestyleCropEnabled) return false; float x = event.getX(); float y = event.getY(); if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) { if (mPreviousTouchX < 0) { mPreviousTouchX = x; mPreviousTouchY = y; } mCurrentTouchCornerIndex = getCurrentTouchIndex(x, y); return mCurrentTouchCornerIndex != -1; } if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_MOVE) { if (event.getPointerCount() == 1 && mCurrentTouchCornerIndex != -1) { x = Math.min(Math.max(x, getPaddingLeft()), getWidth() - getPaddingRight()); y = Math.min(Math.max(y, getPaddingTop()), getHeight() - getPaddingBottom()); updateCropViewRect(x, y); mPreviousTouchX = x; mPreviousTouchY = y; return true; } } if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP) { mPreviousTouchX = -1; mPreviousTouchY = -1; mCurrentTouchCornerIndex = -1; if (mCallback != null) { mCallback.onCropRectUpdated(mCropViewRect); } } return false; } /** * * The order of the corners is: * 0------->1 * ^ | * | 4 | * | v * 3<-------2 */ private void updateCropViewRect(float touchX, float touchY) { mTempRect.set(mCropViewRect); switch (mCurrentTouchCornerIndex) { // resize rectangle case 0: // 是否可拖动裁剪框 if (mIsDragFrame) { mTempRect.set(touchX, touchY, mCropViewRect.right, mCropViewRect.bottom); } break; case 1: // 是否可拖动裁剪框 if (mIsDragFrame) { mTempRect.set(mCropViewRect.left, touchY, touchX, mCropViewRect.bottom); } break; case 2: // 是否可拖动裁剪框 if (mIsDragFrame) { mTempRect.set(mCropViewRect.left, mCropViewRect.top, touchX, touchY); } break; case 3: // 是否可拖动裁剪框 if (mIsDragFrame) { mTempRect.set(touchX, mCropViewRect.top, mCropViewRect.right, touchY); } break; // move rectangle case 4: mTempRect.offset(touchX - mPreviousTouchX, touchY - mPreviousTouchY); if (mTempRect.left > getLeft() && mTempRect.top > getTop() && mTempRect.right < getRight() && mTempRect.bottom < getBottom()) { mCropViewRect.set(mTempRect); updateGridPoints(); postInvalidate(); } return; } boolean changeHeight = mTempRect.height() >= mCropRectMinSize; boolean changeWidth = mTempRect.width() >= mCropRectMinSize; mCropViewRect.set( changeWidth ? mTempRect.left : mCropViewRect.left, changeHeight ? mTempRect.top : mCropViewRect.top, changeWidth ? mTempRect.right : mCropViewRect.right, changeHeight ? mTempRect.bottom : mCropViewRect.bottom); if (changeHeight || changeWidth) { updateGridPoints(); postInvalidate(); } } /** * * The order of the corners in the float array is: * 0------->1 * ^ | * | 4 | * | v * 3<-------2 * * @return - index of corner that is being dragged */ private int getCurrentTouchIndex(float touchX, float touchY) { int closestPointIndex = -1; double closestPointDistance = mTouchPointThreshold; for (int i = 0; i < 8; i += 2) { double distanceToCorner = Math.sqrt(Math.pow(touchX - mCropGridCorners[i], 2) + Math.pow(touchY - mCropGridCorners[i + 1], 2)); if (distanceToCorner < closestPointDistance) { closestPointDistance = distanceToCorner; closestPointIndex = i / 2; } } if (closestPointIndex < 0 && mCropViewRect.contains(touchX, touchY)) { return 4; } // for (int i = 0; i <= 8; i += 2) { // // double distanceToCorner; // if (i < 8) { // corners // distanceToCorner = Math.sqrt(Math.pow(touchX - mCropGridCorners[i], 2) // + Math.pow(touchY - mCropGridCorners[i + 1], 2)); // } else { // center // distanceToCorner = Math.sqrt(Math.pow(touchX - mCropGridCenter[0], 2) // + Math.pow(touchY - mCropGridCenter[1], 2)); // } // if (distanceToCorner < closestPointDistance) { // closestPointDistance = distanceToCorner; // closestPointIndex = i / 2; // } // } return closestPointIndex; } /** * This method draws dimmed area around the crop bounds. * * @param canvas - valid canvas object */ protected void drawDimmedLayer(@NonNull Canvas canvas) { canvas.save(); if (mCircleDimmedLayer) { canvas.clipPath(mCircularPath, Region.Op.DIFFERENCE); } else { canvas.clipRect(mCropViewRect, Region.Op.DIFFERENCE); } canvas.drawColor(mDimmedColor); canvas.restore(); if (mCircleDimmedLayer) { // Draw 1px stroke to fix antialias canvas.drawCircle(mCropViewRect.centerX(), mCropViewRect.centerY(), Math.min(mCropViewRect.width(), mCropViewRect.height()) / 2.f, mDimmedStrokePaint); } } /** * This method draws crop bounds (empty rectangle) * and crop guidelines (vertical and horizontal lines inside the crop bounds) if needed. * * @param canvas - valid canvas object */ protected void drawCropGrid(@NonNull Canvas canvas) { if (mShowCropGrid) { if (mGridPoints == null && !mCropViewRect.isEmpty()) { mGridPoints = new float[(mCropGridRowCount) * 4 + (mCropGridColumnCount) * 4]; int index = 0; for (int i = 0; i < mCropGridRowCount; i++) { mGridPoints[index++] = mCropViewRect.left; mGridPoints[index++] = (mCropViewRect.height() * (((float) i + 1.0f) / (float) (mCropGridRowCount + 1))) + mCropViewRect.top; mGridPoints[index++] = mCropViewRect.right; mGridPoints[index++] = (mCropViewRect.height() * (((float) i + 1.0f) / (float) (mCropGridRowCount + 1))) + mCropViewRect.top; } for (int i = 0; i < mCropGridColumnCount; i++) { mGridPoints[index++] = (mCropViewRect.width() * (((float) i + 1.0f) / (float) (mCropGridColumnCount + 1))) + mCropViewRect.left; mGridPoints[index++] = mCropViewRect.top; mGridPoints[index++] = (mCropViewRect.width() * (((float) i + 1.0f) / (float) (mCropGridColumnCount + 1))) + mCropViewRect.left; mGridPoints[index++] = mCropViewRect.bottom; } } if (mGridPoints != null) { canvas.drawLines(mGridPoints, mCropGridPaint); } } if (mShowCropFrame) { canvas.drawRect(mCropViewRect, mCropFramePaint); } if (mIsFreestyleCropEnabled) { canvas.save(); mTempRect.set(mCropViewRect); mTempRect.inset(mCropRectCornerTouchAreaLineLength, -mCropRectCornerTouchAreaLineLength); canvas.clipRect(mTempRect, Region.Op.DIFFERENCE); mTempRect.set(mCropViewRect); mTempRect.inset(-mCropRectCornerTouchAreaLineLength, mCropRectCornerTouchAreaLineLength); canvas.clipRect(mTempRect, Region.Op.DIFFERENCE); canvas.drawRect(mCropViewRect, mCropFrameCornersPaint); canvas.restore(); } } /** * This method extracts all needed values from the styled attributes. * Those are used to configure the view. */ @SuppressWarnings("deprecation") protected void processStyledAttributes(@NonNull TypedArray a) { mCircleDimmedLayer = a.getBoolean(R.styleable.ucrop_UCropView_ucrop_circle_dimmed_layer, DEFAULT_CIRCLE_DIMMED_LAYER); mDimmedColor = a.getColor(R.styleable.ucrop_UCropView_ucrop_dimmed_color, getResources().getColor(R.color.ucrop_color_default_dimmed)); mDimmedStrokePaint.setColor(mDimmedColor); mDimmedStrokePaint.setStyle(Paint.Style.STROKE); mDimmedStrokePaint.setStrokeWidth(1); initCropFrameStyle(a); mShowCropFrame = a.getBoolean(R.styleable.ucrop_UCropView_ucrop_show_frame, DEFAULT_SHOW_CROP_FRAME); initCropGridStyle(a); mShowCropGrid = a.getBoolean(R.styleable.ucrop_UCropView_ucrop_show_grid, DEFAULT_SHOW_CROP_GRID); } /** * This method setups Paint object for the crop bounds. */ @SuppressWarnings("deprecation") private void initCropFrameStyle(@NonNull TypedArray a) { int cropFrameStrokeSize = a.getDimensionPixelSize(R.styleable.ucrop_UCropView_ucrop_frame_stroke_size, getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_frame_stoke_width)); int cropFrameColor = a.getColor(R.styleable.ucrop_UCropView_ucrop_frame_color, getResources().getColor(R.color.ucrop_color_default_crop_frame)); mCropFramePaint.setStrokeWidth(cropFrameStrokeSize); mCropFramePaint.setColor(cropFrameColor); mCropFramePaint.setStyle(Paint.Style.STROKE); mCropFrameCornersPaint.setStrokeWidth(cropFrameStrokeSize * 3); mCropFrameCornersPaint.setColor(cropFrameColor); mCropFrameCornersPaint.setStyle(Paint.Style.STROKE); } /** * This method setups Paint object for the crop guidelines. */ @SuppressWarnings("deprecation") private void initCropGridStyle(@NonNull TypedArray a) { int cropGridStrokeSize = a.getDimensionPixelSize(R.styleable.ucrop_UCropView_ucrop_grid_stroke_size, getResources().getDimensionPixelSize(R.dimen.ucrop_default_crop_grid_stoke_width)); int cropGridColor = a.getColor(R.styleable.ucrop_UCropView_ucrop_grid_color, getResources().getColor(R.color.ucrop_color_default_crop_grid)); mCropGridPaint.setStrokeWidth(cropGridStrokeSize); mCropGridPaint.setColor(cropGridColor); mCropGridRowCount = a.getInt(R.styleable.ucrop_UCropView_ucrop_grid_row_count, DEFAULT_CROP_GRID_ROW_COUNT); mCropGridColumnCount = a.getInt(R.styleable.ucrop_UCropView_ucrop_grid_column_count, DEFAULT_CROP_GRID_COLUMN_COUNT); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/TransformImageView.java ================================================ package com.matisse.ucrop.view; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Matrix; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.net.Uri; import android.util.AttributeSet; import android.util.Log; import android.widget.ImageView; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.matisse.ucrop.callback.BitmapLoadCallback; import com.matisse.ucrop.model.ExifInfo; import com.matisse.ucrop.util.BitmapLoadUtils; import com.matisse.ucrop.util.FastBitmapDrawable; import com.matisse.ucrop.util.RectUtils; /** * Created by Oleksii Shliama (https://github.com/shliama). *

* This class provides base logic to setup the image, transform it with matrix (move, scale, rotate), * and methods to get current matrix state. */ public class TransformImageView extends ImageView { private static final String TAG = "TransformImageView"; private static final int RECT_CORNER_POINTS_COORDS = 8; private static final int RECT_CENTER_POINT_COORDS = 2; private static final int MATRIX_VALUES_COUNT = 9; protected final float[] mCurrentImageCorners = new float[RECT_CORNER_POINTS_COORDS]; protected final float[] mCurrentImageCenter = new float[RECT_CENTER_POINT_COORDS]; private final float[] mMatrixValues = new float[MATRIX_VALUES_COUNT]; protected Matrix mCurrentImageMatrix = new Matrix(); protected int mThisWidth, mThisHeight; protected TransformImageListener mTransformImageListener; private float[] mInitialImageCorners; private float[] mInitialImageCenter; protected boolean mBitmapDecoded = false; protected boolean mBitmapLaidOut = false; private int mMaxBitmapSize = 0; private Uri mImageInputUri; private String mImageOutputPath; private ExifInfo mExifInfo; /** * Interface for rotation and scale change notifying. */ public interface TransformImageListener { void onLoadComplete(); void onLoadFailure(@NonNull Exception e); void onRotate(float currentAngle); void onScale(float currentScale); } public TransformImageView(Context context) { this(context, null); } public TransformImageView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public TransformImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } public void setTransformImageListener(TransformImageListener transformImageListener) { mTransformImageListener = transformImageListener; } @Override public void setScaleType(ScaleType scaleType) { if (scaleType == ScaleType.MATRIX) { super.setScaleType(scaleType); } else { Log.w(TAG, "Invalid ScaleType. Only ScaleType.MATRIX can be used"); } } /** * Setter for {@link #mMaxBitmapSize} value. * Be sure to call it before {@link #setImageURI(Uri)} or other image setters. * * @param maxBitmapSize - max size for both width and height of bitmap that will be used in the view. */ public void setMaxBitmapSize(int maxBitmapSize) { mMaxBitmapSize = maxBitmapSize; } public int getMaxBitmapSize() { if (mMaxBitmapSize <= 0) { mMaxBitmapSize = BitmapLoadUtils.calculateMaxBitmapSize(getContext()); } return mMaxBitmapSize; } @Override public void setImageBitmap(final Bitmap bitmap) { setImageDrawable(new FastBitmapDrawable(bitmap)); } public Uri getImageInputUri() { return mImageInputUri; } public String getImageOutputPath() { return mImageOutputPath; } public ExifInfo getExifInfo() { return mExifInfo; } /** * This method takes an Uri as a parameter, then calls method to decode it into Bitmap with specified size. * * @param imageUri - image Uri * @throws Exception - can throw exception if having problems with decoding Uri or OOM. */ public void setImageUri(@NonNull Uri imageUri, @Nullable Uri outputUri) throws Exception { int maxBitmapSize = getMaxBitmapSize(); BitmapLoadUtils.decodeBitmapInBackground(getContext(), imageUri, outputUri, maxBitmapSize, maxBitmapSize, new BitmapLoadCallback() { @Override public void onBitmapLoaded(@NonNull Bitmap bitmap, @NonNull ExifInfo exifInfo, @NonNull Uri imageInputUri, @Nullable Uri imageOutputUri) { mImageInputUri = imageInputUri; mImageOutputPath = imageOutputUri.getPath(); mExifInfo = exifInfo; mBitmapDecoded = true; setImageBitmap(bitmap); } @Override public void onFailure(@NonNull Exception bitmapWorkerException) { Log.e(TAG, "onFailure: setImageUri", bitmapWorkerException); if (mTransformImageListener != null) { mTransformImageListener.onLoadFailure(bitmapWorkerException); } } }); } /** * @return - current image scale value. * [1.0f - for original image, 2.0f - for 200% scaled image, etc.] */ public float getCurrentScale() { return getMatrixScale(mCurrentImageMatrix); } /** * This method calculates scale value for given Matrix object. */ public float getMatrixScale(@NonNull Matrix matrix) { return (float) Math.sqrt(Math.pow(getMatrixValue(matrix, Matrix.MSCALE_X), 2) + Math.pow(getMatrixValue(matrix, Matrix.MSKEW_Y), 2)); } /** * @return - current image rotation angle. */ public float getCurrentAngle() { return getMatrixAngle(mCurrentImageMatrix); } /** * This method calculates rotation angle for given Matrix object. */ public float getMatrixAngle(@NonNull Matrix matrix) { return (float) -(Math.atan2(getMatrixValue(matrix, Matrix.MSKEW_X), getMatrixValue(matrix, Matrix.MSCALE_X)) * (180 / Math.PI)); } @Override public void setImageMatrix(Matrix matrix) { super.setImageMatrix(matrix); mCurrentImageMatrix.set(matrix); updateCurrentImagePoints(); } @Nullable public Bitmap getViewBitmap() { if (getDrawable() == null || !(getDrawable() instanceof FastBitmapDrawable)) { return null; } else { return ((FastBitmapDrawable) getDrawable()).getBitmap(); } } /** * This method translates current image. * * @param deltaX - horizontal shift * @param deltaY - vertical shift */ public void postTranslate(float deltaX, float deltaY) { if (deltaX != 0 || deltaY != 0) { mCurrentImageMatrix.postTranslate(deltaX, deltaY); setImageMatrix(mCurrentImageMatrix); } } /** * This method scales current image. * * @param deltaScale - scale value * @param px - scale center X * @param py - scale center Y */ public void postScale(float deltaScale, float px, float py) { if (deltaScale != 0) { mCurrentImageMatrix.postScale(deltaScale, deltaScale, px, py); setImageMatrix(mCurrentImageMatrix); if (mTransformImageListener != null) { mTransformImageListener.onScale(getMatrixScale(mCurrentImageMatrix)); } } } /** * This method rotates current image. * * @param deltaAngle - rotation angle * @param px - rotation center X * @param py - rotation center Y */ public void postRotate(float deltaAngle, float px, float py) { if (deltaAngle != 0) { mCurrentImageMatrix.postRotate(deltaAngle, px, py); setImageMatrix(mCurrentImageMatrix); if (mTransformImageListener != null) { mTransformImageListener.onRotate(getMatrixAngle(mCurrentImageMatrix)); } } } protected void init() { setScaleType(ScaleType.MATRIX); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed || (mBitmapDecoded && !mBitmapLaidOut)) { left = getPaddingLeft(); top = getPaddingTop(); right = getWidth() - getPaddingRight(); bottom = getHeight() - getPaddingBottom(); mThisWidth = right - left; mThisHeight = bottom - top; onImageLaidOut(); } } /** * When image is laid out {@link #mInitialImageCenter} and {@link #mInitialImageCenter} * must be set. */ protected void onImageLaidOut() { final Drawable drawable = getDrawable(); if (drawable == null) { return; } float w = drawable.getIntrinsicWidth(); float h = drawable.getIntrinsicHeight(); Log.d(TAG, String.format("Image size: [%d:%d]", (int) w, (int) h)); RectF initialImageRect = new RectF(0, 0, w, h); mInitialImageCorners = RectUtils.getCornersFromRect(initialImageRect); mInitialImageCenter = RectUtils.getCenterFromRect(initialImageRect); mBitmapLaidOut = true; if (mTransformImageListener != null) { mTransformImageListener.onLoadComplete(); } } /** * This method returns Matrix value for given index. * * @param matrix - valid Matrix object * @param valueIndex - index of needed value. See {@link Matrix#MSCALE_X} and others. * @return - matrix value for index */ protected float getMatrixValue(@NonNull Matrix matrix, @IntRange(from = 0, to = MATRIX_VALUES_COUNT) int valueIndex) { matrix.getValues(mMatrixValues); return mMatrixValues[valueIndex]; } /** * This method logs given matrix X, Y, scale, and angle values. * Can be used for debug. */ @SuppressWarnings("unused") protected void printMatrix(@NonNull String logPrefix, @NonNull Matrix matrix) { float x = getMatrixValue(matrix, Matrix.MTRANS_X); float y = getMatrixValue(matrix, Matrix.MTRANS_Y); float rScale = getMatrixScale(matrix); float rAngle = getMatrixAngle(matrix); Log.d(TAG, logPrefix + ": matrix: { x: " + x + ", y: " + y + ", scale: " + rScale + ", angle: " + rAngle + " }"); } /** * This method updates current image corners and center points that are stored in * {@link #mCurrentImageCorners} and {@link #mCurrentImageCenter} arrays. * Those are used for several calculations. */ private void updateCurrentImagePoints() { mCurrentImageMatrix.mapPoints(mCurrentImageCorners, mInitialImageCorners); mCurrentImageMatrix.mapPoints(mCurrentImageCenter, mInitialImageCenter); } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/UCropView.java ================================================ package com.matisse.ucrop.view; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.view.LayoutInflater; import android.widget.FrameLayout; import androidx.annotation.NonNull; import com.matisse.R; public class UCropView extends FrameLayout { private final GestureCropImageView mGestureCropImageView; private final OverlayView mViewOverlay; public UCropView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public UCropView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); LayoutInflater.from(context).inflate(R.layout.ucrop_view, this, true); mGestureCropImageView = findViewById(R.id.image_view_crop); mViewOverlay = findViewById(R.id.view_overlay); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ucrop_UCropView); mViewOverlay.processStyledAttributes(a); mGestureCropImageView.processStyledAttributes(a); a.recycle(); mGestureCropImageView.setCropBoundsChangeListener(cropRatio -> mViewOverlay.setTargetAspectRatio(cropRatio)); mViewOverlay.setOverlayViewChangeListener(cropRect -> mGestureCropImageView.setCropRect(cropRect)); } @Override public boolean shouldDelayChildPressedState() { return false; } @NonNull public GestureCropImageView getCropImageView() { return mGestureCropImageView; } @NonNull public OverlayView getOverlayView() { return mViewOverlay; } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/widget/AspectRatioTextView.java ================================================ package com.matisse.ucrop.view.widget; import android.annotation.TargetApi; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.os.Build; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import android.text.TextUtils; import android.util.AttributeSet; import android.view.Gravity; import android.widget.TextView; import com.matisse.ucrop.model.AspectRatio; import com.matisse.R; import com.matisse.ucrop.view.CropImageView; import java.util.Locale; /** * Created by Oleksii Shliama (https://github.com/shliama). */ public class AspectRatioTextView extends TextView { private final Rect mCanvasClipBounds = new Rect(); private Paint mDotPaint; private int mDotSize; private float mAspectRatio; private String mAspectRatioTitle; private float mAspectRatioX, mAspectRatioY; public AspectRatioTextView(Context context) { this(context, null); } public AspectRatioTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public AspectRatioTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ucrop_AspectRatioTextView); init(a); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public AspectRatioTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ucrop_AspectRatioTextView); init(a); } /** * @param activeColor the resolved color for active elements */ public void setActiveColor(@ColorInt int activeColor) { applyActiveColor(activeColor); invalidate(); } public void setAspectRatio(@NonNull AspectRatio aspectRatio) { mAspectRatioTitle = aspectRatio.getAspectRatioTitle(); mAspectRatioX = aspectRatio.getAspectRatioX(); mAspectRatioY = aspectRatio.getAspectRatioY(); if (mAspectRatioX == CropImageView.SOURCE_IMAGE_ASPECT_RATIO || mAspectRatioY == CropImageView.SOURCE_IMAGE_ASPECT_RATIO) { mAspectRatio = CropImageView.SOURCE_IMAGE_ASPECT_RATIO; } else { mAspectRatio = mAspectRatioX / mAspectRatioY; } setTitle(); } public float getAspectRatio(boolean toggleRatio) { if (toggleRatio) { toggleAspectRatio(); setTitle(); } return mAspectRatio; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (isSelected()) { canvas.getClipBounds(mCanvasClipBounds); canvas.drawCircle((mCanvasClipBounds.right - mCanvasClipBounds.left) / 2.0f, mCanvasClipBounds.bottom - mDotSize, mDotSize / 2, mDotPaint); } } @SuppressWarnings("deprecation") private void init(@NonNull TypedArray a) { setGravity(Gravity.CENTER_HORIZONTAL); mAspectRatioTitle = a.getString(R.styleable.ucrop_AspectRatioTextView_ucrop_artv_ratio_title); mAspectRatioX = a.getFloat(R.styleable.ucrop_AspectRatioTextView_ucrop_artv_ratio_x, CropImageView.SOURCE_IMAGE_ASPECT_RATIO); mAspectRatioY = a.getFloat(R.styleable.ucrop_AspectRatioTextView_ucrop_artv_ratio_y, CropImageView.SOURCE_IMAGE_ASPECT_RATIO); if (mAspectRatioX == CropImageView.SOURCE_IMAGE_ASPECT_RATIO || mAspectRatioY == CropImageView.SOURCE_IMAGE_ASPECT_RATIO) { mAspectRatio = CropImageView.SOURCE_IMAGE_ASPECT_RATIO; } else { mAspectRatio = mAspectRatioX / mAspectRatioY; } mDotSize = getContext().getResources().getDimensionPixelSize(R.dimen.ucrop_size_dot_scale_text_view); mDotPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mDotPaint.setStyle(Paint.Style.FILL); setTitle(); int activeColor = getResources().getColor(R.color.ucrop_color_widget_active); applyActiveColor(activeColor); a.recycle(); } private void applyActiveColor(@ColorInt int activeColor) { if (mDotPaint != null) { mDotPaint.setColor(activeColor); } ColorStateList textViewColorStateList = new ColorStateList( new int[][]{ new int[]{android.R.attr.state_selected}, new int[]{0} }, new int[]{ activeColor, ContextCompat.getColor(getContext(), R.color.ucrop_color_widget) } ); setTextColor(textViewColorStateList); } private void toggleAspectRatio() { if (mAspectRatio != CropImageView.SOURCE_IMAGE_ASPECT_RATIO) { float tempRatioW = mAspectRatioX; mAspectRatioX = mAspectRatioY; mAspectRatioY = tempRatioW; mAspectRatio = mAspectRatioX / mAspectRatioY; } } private void setTitle() { if (!TextUtils.isEmpty(mAspectRatioTitle)) { setText(mAspectRatioTitle); } else { setText(String.format(Locale.US, "%d:%d", (int) mAspectRatioX, (int) mAspectRatioY)); } } } ================================================ FILE: matisse/src/main/java/com/matisse/ucrop/view/widget/HorizontalProgressWheelView.java ================================================ package com.matisse.ucrop.view.widget; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.os.Build; import androidx.annotation.ColorInt; import androidx.core.content.ContextCompat; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import com.matisse.R; /** * Created by Oleksii Shliama (https://github.com/shliama). */ public class HorizontalProgressWheelView extends View { private final Rect mCanvasClipBounds = new Rect(); private ScrollingListener mScrollingListener; private float mLastTouchedPosition; private Paint mProgressLinePaint; private int mProgressLineWidth, mProgressLineHeight; private int mProgressLineMargin; private boolean mScrollStarted; private float mTotalScrollDistance; private int mMiddleLineColor; public HorizontalProgressWheelView(Context context) { this(context, null); } public HorizontalProgressWheelView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public HorizontalProgressWheelView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public HorizontalProgressWheelView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } public void setScrollingListener(ScrollingListener scrollingListener) { mScrollingListener = scrollingListener; } public void setMiddleLineColor(@ColorInt int middleLineColor) { mMiddleLineColor = middleLineColor; invalidate(); } @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mLastTouchedPosition = event.getX(); break; case MotionEvent.ACTION_UP: if (mScrollingListener != null) { mScrollStarted = false; mScrollingListener.onScrollEnd(); } break; case MotionEvent.ACTION_MOVE: float distance = event.getX() - mLastTouchedPosition; if (distance != 0) { if (!mScrollStarted) { mScrollStarted = true; if (mScrollingListener != null) { mScrollingListener.onScrollStart(); } } onScrollEvent(event, distance); } break; } return true; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.getClipBounds(mCanvasClipBounds); int linesCount = mCanvasClipBounds.width() / (mProgressLineWidth + mProgressLineMargin); float deltaX = (mTotalScrollDistance) % (float) (mProgressLineMargin + mProgressLineWidth); mProgressLinePaint.setColor(getResources().getColor(R.color.ucrop_color_progress_wheel_line)); for (int i = 0; i < linesCount; i++) { if (i < (linesCount / 4)) { mProgressLinePaint.setAlpha((int) (255 * (i / (float) (linesCount / 4)))); } else if (i > (linesCount * 3 / 4)) { mProgressLinePaint.setAlpha((int) (255 * ((linesCount - i) / (float) (linesCount / 4)))); } else { mProgressLinePaint.setAlpha(255); } canvas.drawLine( -deltaX + mCanvasClipBounds.left + i * (mProgressLineWidth + mProgressLineMargin), mCanvasClipBounds.centerY() - mProgressLineHeight / 4.0f, -deltaX + mCanvasClipBounds.left + i * (mProgressLineWidth + mProgressLineMargin), mCanvasClipBounds.centerY() + mProgressLineHeight / 4.0f, mProgressLinePaint); } mProgressLinePaint.setColor(mMiddleLineColor); canvas.drawLine(mCanvasClipBounds.centerX(), mCanvasClipBounds.centerY() - mProgressLineHeight / 2.0f, mCanvasClipBounds.centerX(), mCanvasClipBounds.centerY() + mProgressLineHeight / 2.0f, mProgressLinePaint); } private void onScrollEvent(MotionEvent event, float distance) { mTotalScrollDistance -= distance; postInvalidate(); mLastTouchedPosition = event.getX(); if (mScrollingListener != null) { mScrollingListener.onScroll(-distance, mTotalScrollDistance); } } private void init() { mMiddleLineColor = ContextCompat.getColor(getContext(), R.color.ucrop_color_progress_wheel_line); mProgressLineWidth = getContext().getResources().getDimensionPixelSize(R.dimen.ucrop_width_horizontal_wheel_progress_line); mProgressLineHeight = getContext().getResources().getDimensionPixelSize(R.dimen.ucrop_height_horizontal_wheel_progress_line); mProgressLineMargin = getContext().getResources().getDimensionPixelSize(R.dimen.ucrop_margin_horizontal_wheel_progress_line); mProgressLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mProgressLinePaint.setStyle(Paint.Style.STROKE); mProgressLinePaint.setStrokeWidth(mProgressLineWidth); } public interface ScrollingListener { void onScrollStart(); void onScroll(float delta, float totalDistance); void onScrollEnd(); } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/AlbumPreviewActivity.kt ================================================ package com.matisse.ui.activity import android.database.Cursor import com.matisse.entity.Album import com.matisse.entity.ConstValue import com.matisse.entity.Item import com.matisse.model.AlbumCallbacks import com.matisse.model.AlbumMediaCollection import com.matisse.ui.adapter.PreviewPagerAdapter import kotlinx.android.synthetic.main.activity_media_preview.* /** * Created by liubo on 2018/9/11. */ class AlbumPreviewActivity : BasePreviewActivity(), AlbumCallbacks { private var collection = AlbumMediaCollection() private var isAlreadySetPosition = false override fun setViewData() { super.setViewData() collection.onCreate(this, this) val album = intent.getParcelableExtra(ConstValue.EXTRA_ALBUM) ?: return collection.load(album) val item = intent.getParcelableExtra(ConstValue.EXTRA_ITEM) check_view?.apply { if (spec?.isCountable() == true) { setCheckedNum(selectedCollection.checkedNumOf(item)) } else { setChecked(selectedCollection.isSelected(item)) } } updateSize(item) } override fun onDestroy() { super.onDestroy() collection.onDestroy() } override fun onAlbumLoad(cursor: Cursor) { val items = ArrayList() while (cursor.moveToNext()) { Item.valueOf(cursor)?.run { items.add(this) } } if (items.isEmpty()) return val adapter = pager?.adapter as PreviewPagerAdapter adapter.addAll(items) adapter.notifyDataSetChanged() if (!isAlreadySetPosition) { isAlreadySetPosition = true val selected = intent.getParcelableExtra(ConstValue.EXTRA_ITEM) ?: return val selectedIndex = items.indexOf(selected) pager?.setCurrentItem(selectedIndex, false) previousPos = selectedIndex } } override fun onAlbumReset() { } override fun onAlbumStart() { } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/BaseActivity.kt ================================================ package com.matisse.ui.activity import android.app.Activity import android.content.pm.ActivityInfo import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.matisse.R import com.matisse.entity.IncapableCause import com.matisse.internal.entity.SelectionSpec import com.matisse.utils.handleCause import com.matisse.utils.obtainAttrString abstract class BaseActivity : AppCompatActivity() { lateinit var activity: Activity var spec: SelectionSpec? = null var instanceState: Bundle? = null override fun onCreate(savedInstanceState: Bundle?) { spec = SelectionSpec.getInstance() setTheme(spec?.themeId ?: R.style.Matisse_Default) super.onCreate(savedInstanceState) if (safeCancelActivity()) return activity = this setContentView(getResourceLayoutId()) configActivity() configSaveInstanceState(savedInstanceState) setViewData() initListener() } private fun safeCancelActivity(): Boolean { if (spec?.hasInited == false) { setResult(Activity.RESULT_CANCELED) finish() return true } return false } /** * 处理状态栏(状态栏颜色、状态栏字体颜色、是否隐藏等操作) * * 空实现,供外部重写 */ open fun configActivity() { if (spec?.needOrientationRestriction() == true) { requestedOrientation = spec?.orientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } abstract fun getResourceLayoutId(): Int private fun configSaveInstanceState(savedInstanceState: Bundle?) { instanceState = savedInstanceState } abstract fun setViewData() abstract fun initListener() /** * 获取主题配置中的属性值 * @param attr 主题配置属性key * @param defaultRes 默认值 */ fun getAttrString(attr: Int, defaultRes: Int) = obtainAttrString(this, attr, defaultRes) /** * 抽离提示方法 */ fun handleCauseTips( message: String = "", @IncapableCause.Form form: Int = IncapableCause.TOAST, title: String = "", dismissLoading: Boolean = true ) { handleCause(activity, IncapableCause(form, title, message, dismissLoading)) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/BasePreviewActivity.kt ================================================ package com.matisse.ui.activity import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View import android.view.WindowManager import androidx.viewpager.widget.ViewPager import com.matisse.R import com.matisse.entity.ConstValue import com.matisse.entity.IncapableCause import com.matisse.entity.Item import com.matisse.model.SelectedItemCollection import com.matisse.ucrop.UCrop import com.matisse.ui.adapter.PreviewPagerAdapter import com.matisse.ui.view.PicturePreviewItemFragment import com.matisse.utils.* import com.matisse.widget.CheckView import kotlinx.android.synthetic.main.activity_media_preview.* import kotlinx.android.synthetic.main.include_view_bottom.* /** * desc:BasePreviewActivity
* time: 2018/9/6-11:15
* author:liubo
* since V 1.0.0
*/ open class BasePreviewActivity : BaseActivity(), View.OnClickListener, ViewPager.OnPageChangeListener { lateinit var selectedCollection: SelectedItemCollection var adapter: PreviewPagerAdapter? = null var previousPos = -1 private var originalEnable = false override fun configActivity() { super.configActivity() spec?.statusBarFuture?.invoke(this, null) if (Platform.hasKitKat19()) { window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) } selectedCollection = SelectedItemCollection(this) originalEnable = if (instanceState == null) { selectedCollection.onCreate(intent.getBundleExtra(ConstValue.EXTRA_DEFAULT_BUNDLE)) intent.getBooleanExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, false) } else { selectedCollection.onCreate(instanceState) instanceState!!.getBoolean(ConstValue.CHECK_STATE) } } override fun getResourceLayoutId() = R.layout.activity_media_preview override fun setViewData() { button_preview.setText(getAttrString(R.attr.Preview_Back_text, R.string.button_back)) adapter = PreviewPagerAdapter(supportFragmentManager, null) pager?.adapter = adapter check_view.setCountable(spec?.isCountable() == true) updateApplyButton() } override fun initListener() { setOnClickListener(this, button_preview, button_apply, check_view, original_layout) pager?.addOnPageChangeListener(this) } override fun onSaveInstanceState(outState: Bundle) { selectedCollection.onSaveInstanceState(outState) outState.putBoolean(ConstValue.CHECK_STATE, originalEnable) super.onSaveInstanceState(outState) } override fun onBackPressed() { finishIntentFromPreviewApply(activity, false, selectedCollection, originalEnable) super.onBackPressed() } private fun updateApplyButton() { val selectedCount = selectedCollection.count() setApplyText(selectedCount) if (spec?.originalable == true) { setViewVisible(true, original_layout) updateOriginalState() } else { setViewVisible(false, original_layout) } } private fun setApplyText(selectedCount: Int) { button_apply.apply { when (selectedCount) { 0 -> { text = getString( getAttrString(R.attr.Preview_Confirm_text, R.string.button_sure_default) ) isEnabled = false } 1 -> { isEnabled = true text = if (spec?.singleSelectionModeEnabled() == true) { getString(R.string.button_sure_default) } else { getString( getAttrString( R.attr.Preview_Confirm_text, R.string.button_sure_default ) ).plus("(").plus(selectedCount.toString()).plus(")") } } else -> { isEnabled = true text = getString( getAttrString(R.attr.Preview_Confirm_text, R.string.button_sure_default), "($selectedCount)" ) } } } } private fun updateOriginalState() { original?.setChecked(originalEnable) if (countOverMaxSize(selectedCollection) > 0 || originalEnable) { handleCauseTips( getString(R.string.error_over_original_size, spec?.originalMaxSize), IncapableCause.DIALOG ) original?.setChecked(false) originalEnable = false } } override fun onPageScrollStateChanged(state: Int) { } override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { } override fun onPageSelected(position: Int) { val adapter = pager?.adapter as PreviewPagerAdapter check_view.apply { if (previousPos != -1 && previousPos != position) { (adapter.instantiateItem( pager, previousPos ) as PicturePreviewItemFragment).resetView() val item = adapter.getMediaItem(position) if (spec?.isCountable() == true) { val checkedNum = selectedCollection.checkedNumOf(item) setCheckedNum(checkedNum) if (checkedNum > 0) { setEnable(true) } else { setEnable(!selectedCollection.maxSelectableReached(item)) } } else { val checked = selectedCollection.isSelected(item) setChecked(checked) if (checked) setEnable(true) else setEnable(!selectedCollection.maxSelectableReached(item)) } updateSize(item) } } previousPos = position } fun updateSize(item: Item?) { item?.apply { tv_size.apply { if (isGif()) { setViewVisible(true, this) text = String.format( getString(R.string.picture_size), PhotoMetadataUtils.getSizeInMB(size) ) } else { setViewVisible(false, this) } } original_layout?.apply { if (isVideo()) { setViewVisible(false, this) } else if (spec?.originalable == true) { setViewVisible(true, this) } } } } override fun onClick(v: View?) { when (v) { button_preview -> onBackPressed() button_apply -> { if (spec?.openCrop() == true) { val item = selectedCollection.items()[0] if (spec?.isSupportCrop(item) == true) { item.getContentUri().apply { gotoImageCrop(this@BasePreviewActivity, arrayListOf(this)) } } else { finishIntentFromPreviewApply( activity, true, selectedCollection, originalEnable ) } } else { finishIntentFromPreviewApply(activity, true, selectedCollection, originalEnable) } } original_layout -> { val count = countOverMaxSize(selectedCollection) if (count <= 0) { originalEnable = !originalEnable original?.setChecked(originalEnable) spec?.onCheckedListener?.onCheck(originalEnable) return } handleCauseTips( getString(R.string.error_over_original_count, count, spec?.originalMaxSize), IncapableCause.DIALOG ) } check_view -> { val item = adapter?.getMediaItem(pager.currentItem) if (selectedCollection.isSelected(item)) { selectedCollection.remove(item) if (spec?.isCountable() == true) { check_view.setCheckedNum(CheckView.UNCHECKED) } else { check_view.setChecked(false) } } else { if (assertAddSelection(item)) { selectedCollection.add(item) if (spec?.isCountable() == true) { check_view.setCheckedNum(selectedCollection.checkedNumOf(item)) } else { check_view.setChecked(true) } } } updateApplyButton() spec?.onSelectedListener?.onSelected( selectedCollection.asListOfUri(), selectedCollection.asListOfString() ) } } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode != Activity.RESULT_OK) return when (requestCode) { ConstValue.REQUEST_CODE_CROP -> { data?.run { val resultUri = UCrop.getOutput(data) ?: return@run finishIntentFromCropSuccess(activity, resultUri) } } } } private fun assertAddSelection(item: Item?): Boolean { val cause = selectedCollection.isAcceptable(item) IncapableCause.handleCause(this, cause) return cause == null } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/SelectedPreviewActivity.kt ================================================ package com.matisse.ui.activity import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import com.matisse.entity.ConstValue import com.matisse.entity.Item import kotlinx.android.synthetic.main.activity_media_preview.* /** * desc:图片选中预览
* time: 2019/9/11-14:17
* author:Leo
* since V 1.0.0
*/ class SelectedPreviewActivity : BasePreviewActivity() { companion object { fun instance(context: Context, bundle: Bundle, mOriginalEnable: Boolean) { val intent = Intent(context, SelectedPreviewActivity::class.java) intent.putExtra(ConstValue.EXTRA_DEFAULT_BUNDLE, bundle) .putExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, mOriginalEnable) (context as Activity).startActivityForResult(intent, ConstValue.REQUEST_CODE_PREVIEW) } } override fun setViewData() { super.setViewData() val bundle = intent.getBundleExtra(ConstValue.EXTRA_DEFAULT_BUNDLE) val selected = bundle?.getParcelableArrayList(ConstValue.STATE_SELECTION) selected?.apply { adapter?.addAll(this) adapter?.notifyDataSetChanged() check_view?.apply { if (spec?.isCountable() == true) { setCheckedNum(1) } else { setChecked(true) } } previousPos = 0 updateSize(this[0]) } } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/matisse/AlbumFolderSheetHelper.kt ================================================ package com.matisse.ui.activity.matisse import android.content.Context import android.database.Cursor import android.net.Uri import android.os.Environment import com.matisse.R import com.matisse.entity.Album import com.matisse.ui.view.FolderBottomSheet class AlbumFolderSheetHelper( private var context: Context, private var sheetCallback: FolderBottomSheet.BottomSheetCallback ) { private var albumFolderCursor: Cursor? = null private var albumFolderList: ArrayList? = null private var bottomSheet: FolderBottomSheet? = null private var lastFolderCheckedPosition = 0 fun createFolderSheetDialog() { bottomSheet = FolderBottomSheet.instance( context, lastFolderCheckedPosition, "Folder" ) bottomSheet?.callback = sheetCallback } fun readAlbumFromCursor(): ArrayList? { if (albumFolderList?.size ?: 0 > 0) return albumFolderList if (albumFolderCursor == null) return null var allFolderCoverPath: Uri? = null var allFolderCount = 0L if (albumFolderList == null) { albumFolderList = arrayListOf() } albumFolderCursor?.moveToFirst() while (albumFolderCursor!!.moveToNext()) { val album = Album.valueOf(albumFolderCursor!!) if (albumFolderList?.size == 0) { allFolderCoverPath = album.getCoverPath() } albumFolderList?.add(album) allFolderCount += album.getCount() } albumFolderList?.add( 0, Album(allFolderCoverPath, context.getString(R.string.album_name_all), allFolderCount) ) return albumFolderList } fun insetAlbumToFolder(capturePath: Uri) { readAlbumFromCursor() albumFolderList?.apply { // 全部相册需添加一张 this[0].addCaptureCount() this[0].setCoverPath(capturePath) /** * 拍照后图片保存在Pictures目录下 * Pictures为空时,需手动创建 */ // TODO 2019/10/28 Leo 查询相册下图片需指定id,无法手动生成 // val listDCIM: List? = // filter { Environment.DIRECTORY_PICTURES == it.getDisplayName(context) } // if (listDCIM == null || listDCIM.isEmpty()) { // albumFolderList?.add(Album(Environment.DIRECTORY_PICTURES, 0)) // } // Pictures目录手动添加一张图片 filter { Environment.DIRECTORY_PICTURES == it.getDisplayName(context) }.forEach { it.addCaptureCount() it.setCoverPath(capturePath) } } } /** * 记录上次选中位置 * @return true=记录成功 false=记录失败 */ fun setLastFolderCheckedPosition(lastPosition: Int): Boolean { if (lastFolderCheckedPosition == lastPosition) return false lastFolderCheckedPosition = lastPosition return true } fun setAlbumFolderCursor(cursor: Cursor) { albumFolderCursor = cursor readAlbumFromCursor() } fun getAlbumFolderList() = albumFolderList fun clearFolderSheetDialog() { if (bottomSheet != null && bottomSheet?.adapter != null) { albumFolderCursor = null bottomSheet?.adapter?.setListData(null) } } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/matisse/AlbumLoadHelper.kt ================================================ package com.matisse.ui.activity.matisse import android.os.Bundle import com.matisse.model.AlbumCallbacks import com.matisse.model.AlbumCollection class AlbumLoadHelper( private var activity: MatisseActivity, private var albumLoadCallback: AlbumCallbacks ) { private var albumCollection: AlbumCollection? = null init { albumCollection = AlbumCollection() loadAlbumData() } fun loadAlbumData() { albumCollection?.apply { onCreate(activity, albumLoadCallback) activity.instanceState?.apply { albumCollection?.onRestoreInstanceState(this) } loadAlbums() } } fun onSaveInstanceState(outState: Bundle) { albumCollection?.onSaveInstanceState(outState) } /** * 设置当前选中位置,用于数据回收后恢复 */ fun setStateCurrentSelection(position: Int) { albumCollection?.setStateCurrentSelection(position) } fun onDestroy() { albumCollection?.onDestroy() } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/matisse/IAlbumLoad.kt ================================================ package com.matisse.ui.activity.matisse import android.database.Cursor interface IAlbumLoad { /** * 相册查询完成回调 */ fun onAlbumLoad(cursor: Cursor) /** * */ fun onAlbumReset() } ================================================ FILE: matisse/src/main/java/com/matisse/ui/activity/matisse/MatisseActivity.kt ================================================ package com.matisse.ui.activity.matisse import android.app.Activity import android.content.Intent import android.database.Cursor import android.media.MediaScannerConnection import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.Parcelable import android.view.View import com.matisse.Matisse import com.matisse.R import com.matisse.entity.Album import com.matisse.entity.ConstValue import com.matisse.entity.IncapableCause import com.matisse.entity.Item import com.matisse.model.AlbumCallbacks import com.matisse.model.SelectedItemCollection import com.matisse.ucrop.UCrop import com.matisse.ucrop.UCropMulti import com.matisse.ui.activity.AlbumPreviewActivity import com.matisse.ui.activity.BaseActivity import com.matisse.ui.activity.SelectedPreviewActivity import com.matisse.ui.adapter.AlbumMediaAdapter import com.matisse.ui.adapter.FolderItemMediaAdapter import com.matisse.ui.view.FolderBottomSheet import com.matisse.ui.view.MediaSelectionFragment import com.matisse.utils.* import kotlinx.android.synthetic.main.activity_matisse.* import kotlinx.android.synthetic.main.include_view_bottom.* import kotlinx.android.synthetic.main.include_view_navigation.* /** * desc:入口
* time: 2019/9/11-14:17
* author:Leo
* since V 1.0.0
*/ class MatisseActivity : BaseActivity(), MediaSelectionFragment.SelectionProvider, AlbumMediaAdapter.CheckStateListener, AlbumMediaAdapter.OnMediaClickListener, AlbumMediaAdapter.OnPhotoCapture, View.OnClickListener { private var mediaStoreCompat: MediaStoreCompat? = null private var originalEnable = false private var allAlbum: Album? = null private var albumLoadHelper: AlbumLoadHelper? = null private lateinit var selectedCollection: SelectedItemCollection private lateinit var albumFolderSheetHelper: AlbumFolderSheetHelper override fun configActivity() { super.configActivity() spec?.statusBarFuture?.invoke(this, toolbar) if (spec?.capture == true) { mediaStoreCompat = MediaStoreCompat(this) if (spec?.captureStrategy == null) throw RuntimeException("Don't forget to set CaptureStrategy.") mediaStoreCompat?.setCaptureStrategy(spec?.captureStrategy!!) } } override fun getResourceLayoutId() = R.layout.activity_matisse override fun setViewData() { button_apply.setText(getAttrString(R.attr.Media_Album_text, R.string.album_name_all)) selectedCollection = SelectedItemCollection(this).apply { onCreate(instanceState) } albumLoadHelper = AlbumLoadHelper(this, albumCallback) albumFolderSheetHelper = AlbumFolderSheetHelper(this, albumSheetCallback) updateBottomToolbar() } override fun initListener() { setOnClickListener( this, button_apply, button_preview, original_layout, button_complete, button_back ) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) selectedCollection.onSaveInstanceState(outState) albumLoadHelper?.onSaveInstanceState(outState) outState.putBoolean(ConstValue.CHECK_STATE, originalEnable) } override fun onDestroy() { super.onDestroy() albumLoadHelper?.onDestroy() spec?.onCheckedListener = null spec?.onSelectedListener = null } override fun onBackPressed() { setResult(Activity.RESULT_CANCELED) super.onBackPressed() } override fun onSelectUpdate() { updateBottomToolbar() spec?.onSelectedListener?.onSelected( selectedCollection.asListOfUri(), selectedCollection.asListOfString() ) } override fun capture() { mediaStoreCompat?.dispatchCaptureIntent(this, ConstValue.REQUEST_CODE_CAPTURE) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { ConstValue.REQUEST_CODE_PREVIEW -> { if (resultCode != Activity.RESULT_OK) return val cropPath = Matisse.obtainCropResult(data) // 裁剪带回数据,则认为图片经过裁剪流程 if (cropPath != null) finishIntentFromCrop(activity, cropPath) else doActivityResultFromPreview(data) } ConstValue.REQUEST_CODE_CAPTURE -> doActivityResultFromCapture() ConstValue.REQUEST_CODE_CROP -> { data?.run { val resultUri = UCrop.getOutput(data) finishIntentFromCrop(activity, resultUri) } } ConstValue.REQUEST_CODE_CROP_ERROR -> { data?.run { val cropError = UCrop.getError(data)?.message ?: "" IncapableCause.handleCause(activity, IncapableCause(cropError)) } } } } override fun onClick(v: View?) { when (v) { button_back -> onBackPressed() button_preview -> { if (selectedCollection.count() == 0) { handleCauseTips(getString(R.string.please_select_media_resource)) return } SelectedPreviewActivity.instance( activity, selectedCollection.getDataWithBundle(), originalEnable ) } button_complete -> { if (selectedCollection.count() == 0) { handleCauseTips(getString(R.string.please_select_media_resource)) return } val item = selectedCollection.asList()[0] if (spec?.openCrop() == true && spec?.isSupportCrop(item) == true) { gotoImageCrop(this, selectedCollection.asListOfUri() as ArrayList) return } handleIntentFromPreview(activity, originalEnable, selectedCollection.items()) } original_layout -> { val count = countOverMaxSize(selectedCollection) if (count <= 0) { originalEnable = !originalEnable original.setChecked(originalEnable) spec?.onCheckedListener?.onCheck(originalEnable) return } handleCauseTips( getString(R.string.error_over_original_count, count, spec?.originalMaxSize), IncapableCause.DIALOG ) } button_apply -> { if (allAlbum?.isAll() == true && allAlbum?.isEmpty() == true) { handleCauseTips(getString(R.string.empty_album)) return } albumFolderSheetHelper.createFolderSheetDialog() } } } override fun provideSelectedItemCollection() = selectedCollection override fun onMediaClick(album: Album?, item: Item, adapterPosition: Int) { val intent = Intent(this, AlbumPreviewActivity::class.java) .putExtra(ConstValue.EXTRA_ALBUM, album as Parcelable) .putExtra(ConstValue.EXTRA_ITEM, item) .putExtra(ConstValue.EXTRA_DEFAULT_BUNDLE, selectedCollection.getDataWithBundle()) .putExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, originalEnable) startActivityForResult(intent, ConstValue.REQUEST_CODE_PREVIEW) } /** * 处理预览的[onActivityResult] */ private fun doActivityResultFromPreview(data: Intent?) { data?.apply { originalEnable = getBooleanExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, false) val isApplyData = getBooleanExtra(ConstValue.EXTRA_RESULT_APPLY, false) handlePreviewIntent(activity, data, originalEnable, isApplyData, selectedCollection) if (!isApplyData) { val mediaSelectionFragment = supportFragmentManager.findFragmentByTag( MediaSelectionFragment::class.java.simpleName ) if (mediaSelectionFragment is MediaSelectionFragment) { mediaSelectionFragment.refreshMediaGrid() } updateBottomToolbar() } } } /** * 处理拍照的[onActivityResult] */ private fun doActivityResultFromCapture() { val capturePathUri = mediaStoreCompat?.getCurrentPhotoUri() ?: return val capturePath = mediaStoreCompat?.getCurrentPhotoPath() ?: return // 刷新系统相册 MediaScannerConnection.scanFile(this, arrayOf(capturePath), null, null) // 重新获取相册数据 albumLoadHelper?.loadAlbumData() // 手动插入到相册列表 albumFolderSheetHelper.insetAlbumToFolder(capturePathUri) // 重新load所有资源 albumFolderSheetHelper.getAlbumFolderList()?.apply { onAlbumSelected(this[0]) } // Check is Crop first if (spec?.openCrop() == true) { gotoImageCrop(this, arrayListOf(capturePathUri)) } } private fun updateBottomToolbar() { val selectedCount = selectedCollection.count() setCompleteText(selectedCount) if (spec?.originalable == true) { setViewVisible(true, original_layout) updateOriginalState() } else { setViewVisible(false, original_layout) } } private fun setCompleteText(selectedCount: Int) { if (selectedCount == 0) { button_complete.setText(getAttrString(R.attr.Media_Sure_text, R.string.button_sure)) } else if (selectedCount == 1 && spec?.singleSelectionModeEnabled() == true) { button_complete.setText(getAttrString(R.attr.Media_Sure_text, R.string.button_sure)) } else { button_complete.text = getString(getAttrString(R.attr.Media_Sure_text, R.string.button_sure)) .plus("(").plus(selectedCount.toString()).plus(")") } } private fun updateOriginalState() { original.setChecked(originalEnable) if (countOverMaxSize(selectedCollection) > 0 || originalEnable) { handleCauseTips( getString(R.string.error_over_original_size, spec?.originalMaxSize), IncapableCause.DIALOG ) original.setChecked(false) originalEnable = false } } private fun onAlbumSelected(album: Album) { if (album.isAll() && album.isEmpty()) { setViewVisible(true, empty_view) setViewVisible(false, container) } else { setViewVisible(false, empty_view) setViewVisible(true, container) val fragment = MediaSelectionFragment.newInstance(album) supportFragmentManager.beginTransaction() .replace(container.id, fragment, MediaSelectionFragment::class.java.simpleName) .commitAllowingStateLoss() } } private var albumCallback = object : AlbumCallbacks { override fun onAlbumStart() { // do nothing } override fun onAlbumLoad(cursor: Cursor) { albumFolderSheetHelper.setAlbumFolderCursor(cursor) Handler(Looper.getMainLooper()).post { if (cursor.moveToFirst()) { allAlbum = Album.valueOf(cursor).apply { onAlbumSelected(this) } } } } override fun onAlbumReset() { albumFolderSheetHelper.clearFolderSheetDialog() } } private var albumSheetCallback = object : FolderBottomSheet.BottomSheetCallback { override fun initData(adapter: FolderItemMediaAdapter) { adapter.setListData(albumFolderSheetHelper.readAlbumFromCursor()) } override fun onItemClick(album: Album, position: Int) { if (!albumFolderSheetHelper.setLastFolderCheckedPosition(position)) return albumLoadHelper?.setStateCurrentSelection(position) button_apply.text = album.getDisplayName(activity) onAlbumSelected(album) } } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/adapter/AlbumMediaAdapter.kt ================================================ package com.matisse.ui.adapter import android.content.Context import android.database.Cursor import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.matisse.R import com.matisse.entity.Album import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.model.SelectedItemCollection import com.matisse.utils.handleCause import com.matisse.utils.setTextDrawable import com.matisse.widget.CheckView import com.matisse.widget.MediaGrid class AlbumMediaAdapter( private var context: Context, private var selectedCollection: SelectedItemCollection, private var recyclerView: RecyclerView ) : RecyclerViewCursorAdapter(null), MediaGrid.OnMediaGridClickListener { private var placeholder: Drawable? = null private var selectionSpec: SelectionSpec = SelectionSpec.getInstance() var checkStateListener: CheckStateListener? = null var onMediaClickListener: OnMediaClickListener? = null private var imageResize = 0 private var layoutInflater: LayoutInflater init { val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.Item_placeholder)) placeholder = ta.getDrawable(0) ta.recycle() layoutInflater = LayoutInflater.from(context) } companion object { const val VIEW_TYPE_CAPTURE = 0X01 const val VIEW_TYPE_MEDIA = 0X02 } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_CAPTURE -> { val v = layoutInflater.inflate(R.layout.item_photo_capture, parent, false) CaptureViewHolder(v).run { itemView.setOnClickListener { if (it.context is OnPhotoCapture) (it.context as OnPhotoCapture).capture() } this } } else -> { val v = layoutInflater.inflate(R.layout.item_media_grid, parent, false) MediaViewHolder(v) } } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, cursor: Cursor, position: Int) { holder.apply { when (this) { is CaptureViewHolder -> setTextDrawable(itemView.context, hint, R.attr.Media_Camera_textColor) is MediaViewHolder -> { val item = Item.valueOf(cursor, position) mediaGrid.preBindMedia( MediaGrid.PreBindInfo( getImageResize(mediaGrid.context), placeholder, selectionSpec.isCountable(), holder ) ) item?.let { mediaGrid.bindMedia(it) mediaGrid.listener = this@AlbumMediaAdapter setCheckStatus(it, mediaGrid) } } } } } override fun getItemViewType(position: Int, cursor: Cursor) = if (Item.valueOf(cursor)?.isCapture() == true) VIEW_TYPE_CAPTURE else VIEW_TYPE_MEDIA private fun getImageResize(context: Context): Int { if (imageResize != 0) return imageResize val layoutManager = recyclerView.layoutManager as GridLayoutManager val spanCount = layoutManager.spanCount val screenWidth = context.resources.displayMetrics.widthPixels val availableWidth = screenWidth - context.resources.getDimensionPixelSize( R.dimen.media_grid_spacing ) * (spanCount - 1) imageResize = availableWidth / spanCount imageResize = (imageResize * selectionSpec.thumbnailScale).toInt() return imageResize } /** * 初始化选择框选中状态 */ private fun setCheckStatus(item: Item, mediaGrid: MediaGrid) { // 初始化时 添加上次选中的图片 setLastChooseItems(item) if (selectionSpec.isCountable()) { val checkedNum = selectedCollection.checkedNumOf(item) if (checkedNum > 0) { mediaGrid.setCheckedNum(checkedNum) } else { mediaGrid.setCheckedNum( if (selectedCollection.maxSelectableReached(item)) CheckView.UNCHECKED else checkedNum ) } } else { mediaGrid.setChecked(selectedCollection.isSelected(item)) } } override fun onThumbnailClicked( thumbnail: ImageView, item: Item, holder: RecyclerView.ViewHolder ) { onMediaClickListener?.onMediaClick(null, item, holder.adapterPosition) } /** * 单选: * a.选中:刷新当前项与上次选择项 * b.取消选中:刷新当前项与上次选择项 * * 多选: * 1. 按序号计数 * a.选中:仅刷新选中的item * b.取消选中: * 取消最后一位:仅刷新当前操作的item * 取消非最后一位:刷新所有选中的item * 2. 无序号计数 * a.选中:仅刷新选中的item * b.取消选中:仅刷新选中的item */ override fun onCheckViewClicked( checkView: CheckView, item: Item, holder: RecyclerView.ViewHolder ) { if (selectionSpec.isSingleChoose()) { notifySingleChooseData(item) } else { notifyMultiChooseData(item) } } /** * 单选刷新数据 */ private fun notifySingleChooseData(item: Item) { if (selectedCollection.isSelected(item)) { selectedCollection.remove(item) notifyItemChanged(item.positionInList) } else { notifyLastItem() if (!addItem(item)) return notifyItemChanged(item.positionInList) } notifyCheckStateChanged() } private fun notifyLastItem() { val itemLists = selectedCollection.asList() if (itemLists.size > 0) { selectedCollection.remove(itemLists[0]) notifyItemChanged(itemLists[0].positionInList) } } /** * 多选刷新数据 * 1. 按序号计数 * a.选中:仅刷新选中的item * b.取消选中: * 取消最后一位:仅刷新当前操作的item * 取消非最后一位:刷新所有选中的item * 2. 无序号计数 * a.选中:仅刷新选中的item * b.取消选中:仅刷新选中的item */ private fun notifyMultiChooseData(item: Item) { if (selectionSpec.isCountable()) { if (notifyMultiCountableItem(item)) return } else { if (selectedCollection.isSelected(item)) { selectedCollection.remove(item) } else { if (!addItem(item)) return } notifyItemChanged(item.positionInList) } notifyCheckStateChanged() } /** * @return 是否拦截 true=拦截 false=不拦截 */ private fun notifyMultiCountableItem(item: Item): Boolean { val checkedNum = selectedCollection.checkedNumOf(item) if (checkedNum == CheckView.UNCHECKED) { if (!addItem(item)) return true notifyItemChanged(item.positionInList) } else { selectedCollection.remove(item) // 取消选中中间序号时,刷新所有选中item if (checkedNum != selectedCollection.count() + 1) { selectedCollection.asList().forEach { notifyItemChanged(it.positionInList) } } notifyItemChanged(item.positionInList) } return false } private fun notifyCheckStateChanged() { checkStateListener?.onSelectUpdate() } private fun addItem(item: Item): Boolean { if (!assertAddSelection(context, item)) return false selectedCollection.add(item) return true } private fun assertAddSelection(context: Context, item: Item): Boolean { val cause = selectedCollection.isAcceptable(item) handleCause(context, cause) return cause == null } /** * 初始化外部传入上次选中的图片 */ private fun setLastChooseItems(item: Item) { if (selectionSpec.lastChoosePictureIdsOrUris == null) return selectionSpec.lastChoosePictureIdsOrUris?.forEachIndexed { index, s -> if (s == item.id.toString() || s == item.getContentUri().toString()) { selectedCollection.add(item) selectionSpec.lastChoosePictureIdsOrUris!![index] = "" } } } interface CheckStateListener { fun onSelectUpdate() } interface OnMediaClickListener { fun onMediaClick(album: Album?, item: Item, adapterPosition: Int) } interface OnPhotoCapture { fun capture() } class MediaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var mediaGrid: MediaGrid = itemView as MediaGrid } class CaptureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var hint: TextView = itemView.findViewById(R.id.hint) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/adapter/FolderItemMediaAdapter.kt ================================================ package com.matisse.ui.adapter import android.content.Context import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.matisse.R import com.matisse.entity.Album import com.matisse.internal.entity.SelectionSpec import com.matisse.widget.CheckRadioView class FolderItemMediaAdapter(var context: Context, var mCurrentPosition: Int) : RecyclerView.Adapter() { var albumList = arrayListOf() private var inflater: LayoutInflater var itemClickListener: OnItemClickListener? = null private var placeholder: Drawable? init { val ta = context.theme.obtainStyledAttributes(intArrayOf(R.attr.Item_placeholder)) placeholder = ta.getDrawable(0) ta.recycle() inflater = LayoutInflater.from(context) } override fun onBindViewHolder(holder: FolderViewHolder, position: Int) { val album = albumList[position] holder.tvBucketName.text = String.format( context.getString(R.string.folder_count), album.getDisplayName(holder.tvBucketName.context), album.getCount() ) setRbSelectChecked(holder.rbSelected, position == mCurrentPosition) // do not need to load animated Gif val mContext = holder.ivBucketCover.context SelectionSpec.getInstance().imageEngine?.loadThumbnail( mContext, mContext.resources.getDimensionPixelSize(R.dimen.media_grid_size), placeholder, holder.ivBucketCover, album.getCoverPath() ) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = FolderViewHolder( parent, inflater.inflate(R.layout.item_album_folder, parent, false) ) override fun getItemCount() = albumList.size fun setListData(list: MutableList?) { albumList.clear() list?.apply { albumList.addAll(this) } notifyDataSetChanged() } inner class FolderViewHolder(private val mParentView: ViewGroup, itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener { var tvBucketName: TextView = itemView.findViewById(R.id.tv_bucket_name) var ivBucketCover: ImageView = itemView.findViewById(R.id.iv_bucket_cover) var rbSelected: CheckRadioView = itemView.findViewById(R.id.rb_selected) init { itemView.setOnClickListener(this) } override fun onClick(v: View) { itemClickListener?.onItemClick(v, layoutPosition) mCurrentPosition = layoutPosition setRadioDisChecked(mParentView) setRbSelectChecked(rbSelected, true) } /** * 设置未所有Item为未选中 */ private fun setRadioDisChecked(parentView: ViewGroup?) { if (parentView == null || parentView.childCount < 1) return for (i in 0 until parentView.childCount) { val itemView = parentView.getChildAt(i) val rbSelect: CheckRadioView = itemView.findViewById(R.id.rb_selected) setRbSelectChecked(rbSelect, false) } } } private fun setRbSelectChecked(rbSelect: CheckRadioView?, checked: Boolean) { rbSelect?.apply { scaleX = if (checked) 1f else 0f scaleY = if (checked) 1f else 0f setChecked(checked) } } interface OnItemClickListener { fun onItemClick(view: View, position: Int) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/adapter/PicturePreviewPagerAdapter.kt ================================================ package com.matisse.ui.adapter import android.util.SparseArray import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.viewpager.widget.PagerAdapter import com.matisse.R import com.matisse.entity.Item class PicturePreviewPagerAdapter(listener: OnPrimaryItemSetListener?) : PagerAdapter() { /** * 最大缓存图片数量 */ private val MAX_CACHE_SIZE = 18 /** * 缓存view */ private var mCacheView: SparseArray? = null fun clear() { if (null != mCacheView) { mCacheView!!.clear() mCacheView = null } } var items: ArrayList = ArrayList() var kListener: OnPrimaryItemSetListener? = null init { this.kListener = listener } override fun getCount() = items.size override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { container.removeView(`object` as View) if (mCacheView?.size() ?: 0 > MAX_CACHE_SIZE) { mCacheView?.remove(position) } } override fun isViewFromObject(view: View, `object`: Any) = view == `object` override fun instantiateItem(container: ViewGroup, position: Int): View { var contentView = mCacheView?.get(position) if (contentView == null) { contentView = LayoutInflater.from(container.context) .inflate(R.layout.fragment_preview_item, container, false) items[position].run { } mCacheView?.put(position, contentView) } container.addView(contentView, 0) return contentView!! } override fun setPrimaryItem(container: ViewGroup, position: Int, `object`: Any) { super.setPrimaryItem(container, position, `object`) kListener?.onPrimaryItemSet(position) } fun getMediaItem(position: Int): Item? { if (count > position) { return items[position] } return null } fun addAll(items: List) { this.items.addAll(items) } interface OnPrimaryItemSetListener { fun onPrimaryItemSet(position: Int) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/adapter/PreviewPagerAdapter.kt ================================================ package com.matisse.ui.adapter import android.view.ViewGroup import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentStatePagerAdapter import com.matisse.entity.Item import com.matisse.ui.view.PicturePreviewItemFragment /** * Created by liubo on 2018/9/6. */ class PreviewPagerAdapter(manager: FragmentManager, listener: OnPrimaryItemSetListener?) : FragmentStatePagerAdapter(manager) { var items: ArrayList = ArrayList() var kListener: OnPrimaryItemSetListener? = null init { this.kListener = listener } override fun getCount() = items.size override fun getItem(position: Int) = PicturePreviewItemFragment.newInstance(items[position]) override fun setPrimaryItem(container: ViewGroup, position: Int, `object`: Any) { super.setPrimaryItem(container, position, `object`) kListener?.onPrimaryItemSet(position) } fun getMediaItem(position: Int): Item? { if (count > position) { return items[position] } return null } fun addAll(items: List) { this.items.addAll(items) } interface OnPrimaryItemSetListener { fun onPrimaryItemSet(position: Int) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/adapter/RecyclerViewCursorAdapter.kt ================================================ package com.matisse.ui.adapter import android.database.Cursor import android.provider.MediaStore import androidx.recyclerview.widget.RecyclerView abstract class RecyclerViewCursorAdapter(c: Cursor?) : RecyclerView.Adapter() { private var cursor: Cursor? = null private var rowIDColumn = 0 init { setHasStableIds(true) swapCursor(c) } abstract fun onBindViewHolder(holder: VH, cursor: Cursor, position: Int) override fun onBindViewHolder(holder: VH, position: Int) { check(isDataValid(cursor)) { "Cannot bind view holder when cursor is in invalid state." } check(cursor?.moveToPosition(position)!!) { "Could not move cursor to position $position when trying to bind view holder" } onBindViewHolder(holder, cursor!!, position) } override fun getItemViewType(position: Int): Int { check(cursor?.moveToPosition(position)!!) { "Could not move cursor to position $position when trying to get item view type." } return getItemViewType(position, cursor!!) } abstract fun getItemViewType(position: Int, cursor: Cursor): Int override fun getItemCount() = if (isDataValid(cursor)) cursor?.count!! else 0 override fun getItemId(position: Int): Long { check(isDataValid(cursor)) { "Cannot lookup item id when cursor is in invalid state." } check(cursor?.moveToPosition(position)!!) { "Could not move cursor to position $position when trying to get an item id" } return cursor?.getLong(rowIDColumn) ?: 0 } fun swapCursor(newCursor: Cursor?) { if (newCursor == cursor) return if (newCursor == null) { notifyItemRangeRemoved(0, itemCount) cursor = null rowIDColumn = -1 } else { cursor = newCursor rowIDColumn = cursor?.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) ?: 0 // notify the observers about the new cursor notifyDataSetChanged() } } private fun isDataValid(cursor: Cursor?) = cursor != null && !cursor.isClosed fun getCursor() = cursor } ================================================ FILE: matisse/src/main/java/com/matisse/ui/view/BottomSheetDialogFragment.kt ================================================ package com.matisse.ui.view import android.os.Bundle import android.util.DisplayMetrics import android.view.* import android.widget.FrameLayout import androidx.appcompat.app.AppCompatDialogFragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.matisse.R abstract class BottomSheetDialogFragment : AppCompatDialogFragment() { private lateinit var kBehavior: BottomSheetBehavior<*> private var coordinator: ViewGroup? = null private var bottomSheet: FrameLayout? = null private var contentView: View? = null private var defaultHeight = -1 private var kCancelable = true private var mBottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { override fun onSlide(bottomSheet: View, slideOffset: Float) { // do noting } override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_HIDDEN) dismiss() } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { coordinator = inflater.inflate(R.layout.dialog_bottom_sheet, container) as ViewGroup bottomSheet = coordinator?.findViewById(R.id.design_bottom_sheet) kBehavior = BottomSheetBehavior.from(bottomSheet) kBehavior.setBottomSheetCallback(mBottomSheetCallback) kBehavior.isHideable = kCancelable contentView = getContentView(inflater, coordinator!!) bottomSheet?.addView(contentView) if (defaultHeight != -1) { setDefaultHeight(defaultHeight) } // 设置 dialog 位于屏幕底部,并且设置出入动画 setBottomLayout() setPeekHeight() initBackAction() return coordinator } fun setDefaultHeight(defaultHeight: Int) { this.defaultHeight = defaultHeight if (bottomSheet != null) { bottomSheet?.layoutParams?.width = -1 bottomSheet?.layoutParams?.height = defaultHeight } } private fun setPeekHeight() { val dm = DisplayMetrics() //取得窗口属性 activity?.windowManager?.defaultDisplay?.getMetrics(dm) //窗口高度 val screenHeight = dm.heightPixels kBehavior.peekHeight = screenHeight } private fun initBackAction() { dialog?.setOnKeyListener { _, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { backAction() } else false } } override fun setCancelable(cancelable: Boolean) { super.setCancelable(cancelable) if (kCancelable != cancelable) { kCancelable = cancelable kBehavior.isHideable = cancelable } } private fun setBottomLayout() { dialog?.window?.apply { setBackgroundDrawableResource(R.drawable.transparent) decorView.setPadding(0, 0, 0, 0) attributes.width = WindowManager.LayoutParams.MATCH_PARENT attributes.height = WindowManager.LayoutParams.WRAP_CONTENT // dialog 布局位于底部 setGravity(Gravity.BOTTOM) // 设置进出场动画 setWindowAnimations(R.style.Animation_Bottom) } } abstract fun getContentView(inflater: LayoutInflater, container: ViewGroup): View open fun backAction() = false } ================================================ FILE: matisse/src/main/java/com/matisse/ui/view/FolderBottomSheet.kt ================================================ package com.matisse.ui.view import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.matisse.R import com.matisse.entity.Album import com.matisse.entity.ConstValue import com.matisse.ui.adapter.FolderItemMediaAdapter import com.matisse.utils.getScreenHeight class FolderBottomSheet : BottomSheetDialogFragment() { private var kParentView: View? = null private lateinit var recyclerView: RecyclerView var adapter: FolderItemMediaAdapter? = null var callback: BottomSheetCallback? = null private var currentPosition = 0 companion object { fun instance(context: Context, currentPos: Int, tag: String): FolderBottomSheet { val bottomSheet = FolderBottomSheet() val bundle = Bundle() bundle.putInt(ConstValue.FOLDER_CHECK_POSITION, currentPos) bottomSheet.arguments = bundle bottomSheet.show((context as FragmentActivity).supportFragmentManager, tag) return bottomSheet } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) currentPosition = arguments?.getInt(ConstValue.FOLDER_CHECK_POSITION, 0) ?: 0 } override fun getContentView(inflater: LayoutInflater, container: ViewGroup): View { if (kParentView == null) { kParentView = inflater.inflate(R.layout.dialog_bottom_sheet_folder, container, false) setDefaultHeight(getScreenHeight(context!!) / 2) initView() } else { if (kParentView?.parent != null) { val parent = kParentView?.parent as ViewGroup parent.removeView(view) } } return kParentView!! } private fun initView() { recyclerView = kParentView?.findViewById(R.id.recyclerview)!! recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.setHasFixedSize(true) setRecyclerViewHeight() adapter = FolderItemMediaAdapter(context!!, currentPosition).apply { recyclerView.adapter = this callback?.initData(this) itemClickListener = object : FolderItemMediaAdapter.OnItemClickListener { override fun onItemClick(view: View, position: Int) { dismiss() callback?.onItemClick(albumList[position], position) } } } } private fun setRecyclerViewHeight() { recyclerView.layoutParams.height = getScreenHeight(context!!) / 2 } interface BottomSheetCallback { fun initData(adapter: FolderItemMediaAdapter) /** * 点击回调 * @param album 当前选中的相册 * @param position 当前选中的位置 */ fun onItemClick(album: Album, position: Int) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/view/MediaSelectionFragment.kt ================================================ package com.matisse.ui.view import android.content.Context import android.database.Cursor import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.recyclerview.widget.GridLayoutManager import com.matisse.R import com.matisse.entity.Album import com.matisse.entity.ConstValue import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.model.AlbumCallbacks import com.matisse.model.AlbumMediaCollection import com.matisse.model.SelectedItemCollection import com.matisse.ui.adapter.AlbumMediaAdapter import com.matisse.utils.MAX_SPAN_COUNT import com.matisse.utils.spanCount import com.matisse.widget.MediaGridInset import kotlinx.android.synthetic.main.fragment_media_selection.* import kotlin.math.max import kotlin.math.min class MediaSelectionFragment : Fragment(), AlbumCallbacks, AlbumMediaAdapter.CheckStateListener, AlbumMediaAdapter.OnMediaClickListener { private val albumMediaCollection = AlbumMediaCollection() private lateinit var adapter: AlbumMediaAdapter private lateinit var album: Album private lateinit var selectionProvider: SelectionProvider private lateinit var checkStateListener: AlbumMediaAdapter.CheckStateListener private lateinit var onMediaClickListener: AlbumMediaAdapter.OnMediaClickListener companion object { fun newInstance(album: Album): MediaSelectionFragment { val fragment = MediaSelectionFragment() fragment.arguments = Bundle().apply { putParcelable(ConstValue.EXTRA_ALBUM, album) } return fragment } } override fun onAttach(context: Context) { super.onAttach(context) if (context is SelectionProvider) { selectionProvider = context } else { throw IllegalStateException("Context must implement SelectionProvider.") } if (context is AlbumMediaAdapter.CheckStateListener) checkStateListener = context if (context is AlbumMediaAdapter.OnMediaClickListener) onMediaClickListener = context } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = inflater.inflate(R.layout.fragment_media_selection, container, false) override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) album = arguments?.getParcelable(ConstValue.EXTRA_ALBUM)!! adapter = AlbumMediaAdapter( context!!, selectionProvider.provideSelectedItemCollection(), recyclerview ) adapter.checkStateListener = this adapter.onMediaClickListener = this recyclerview.setHasFixedSize(true) val selectionSpec = SelectionSpec.getInstance() val spanCount = if (selectionSpec.gridExpectedSize > 0) { spanCount(context!!, selectionSpec.gridExpectedSize) } else { max(min(selectionSpec.spanCount, MAX_SPAN_COUNT), 1) } recyclerview.layoutManager = GridLayoutManager(context!!, spanCount) val spacing = resources.getDimensionPixelSize(R.dimen.media_grid_spacing) recyclerview.addItemDecoration(MediaGridInset(spanCount, spacing, false)) recyclerview.itemAnimator?.changeDuration = 0 recyclerview.adapter = adapter albumMediaCollection.onCreate(activity!!, this) albumMediaCollection.load(album, selectionSpec.capture) } fun refreshMediaGrid() { adapter.notifyDataSetChanged() } override fun onMediaClick(album: Album?, item: Item, adapterPosition: Int) { onMediaClickListener.onMediaClick(this.album, item, adapterPosition) } override fun onSelectUpdate() { checkStateListener.onSelectUpdate() } override fun onAlbumStart() { // do nothing } override fun onAlbumLoad(cursor: Cursor) { adapter.swapCursor(cursor) } override fun onAlbumReset() { adapter.swapCursor(null) } override fun onDestroyView() { super.onDestroyView() albumMediaCollection.onDestroy() } interface SelectionProvider { fun provideSelectedItemCollection(): SelectedItemCollection } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/view/PicturePreviewItemFragment.kt ================================================ package com.matisse.ui.view import android.content.Intent import android.graphics.Point import android.graphics.PointF import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment import com.matisse.R import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.photoview.PhotoView import com.matisse.utils.PhotoMetadataUtils import com.matisse.widget.longimage.ImageSource import com.matisse.widget.longimage.ImageViewState import com.matisse.widget.longimage.SubsamplingScaleImageView import it.sephiroth.android.library.imagezoom.ImageViewTouch /** * desc: 预览界面真正载体
* time: 2020-03-30-20:05
* author: Leo
* since V 2.1
*/ class PicturePreviewItemFragment : Fragment() { companion object { private const val ARGS_ITEM = "args_item" fun newInstance(item: Item): PicturePreviewItemFragment { val fragment = PicturePreviewItemFragment() val bundle = Bundle() bundle.putParcelable(ARGS_ITEM, item) fragment.arguments = bundle return fragment } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = inflater.inflate(R.layout.fragment_picture_preview_item, container, false) override fun onViewCreated(contentView: View, savedInstanceState: Bundle?) { super.onViewCreated(contentView, savedInstanceState) val media: Item = arguments!!.getParcelable(ARGS_ITEM) ?: return val videoPlayButton: View = contentView.findViewById(R.id.video_play_button) if (media.isVideo()) { videoPlayButton.visibility = View.VISIBLE videoPlayButton.setOnClickListener { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(media.getContentUri(), "video/*") if (intent.resolveActivity(activity!!.packageManager) != null) startActivity(intent) else Toast.makeText( context, R.string.error_no_video_activity, Toast.LENGTH_SHORT ).show() } } else { videoPlayButton.visibility = View.GONE } // 常规图控件 val imageView: PhotoView = contentView.findViewById(R.id.preview_image) // 长图控件 val longImg: SubsamplingScaleImageView = contentView.findViewById(R.id.longImg) val size: Point = PhotoMetadataUtils.getBitmapSize(media.getContentUri(), activity) // TODO Leo 2020-03-30 长图判断不能这么写,后续完善 val isLongImg = PhotoMetadataUtils.isLongImg(size) val isGifImg = media.isGif() imageView.visibility = if (isLongImg && !isGifImg) View.GONE else View.VISIBLE longImg.visibility = if (isLongImg && !isGifImg) View.VISIBLE else View.GONE if (isGifImg) { SelectionSpec.getInstance().imageEngine?.loadGifImage( context!!, size.x, size.y, imageView, media.getContentUri() ) } else { if (isLongImg) { displayLongPic(media.getContentUri(), longImg) } else { SelectionSpec.getInstance().imageEngine?.loadImage( context!!, size.x, size.y, imageView, media.getContentUri() ) } } } fun resetView() { val image: ImageViewTouch? = view?.findViewById(R.id.image_view) image?.resetMatrix() } /** * 加载长图 * * @param uri * @param longImg */ private fun displayLongPic(uri: Uri, longImg: SubsamplingScaleImageView) { longImg.isQuickScaleEnabled = true longImg.isZoomEnabled = true longImg.isPanEnabled = true longImg.setDoubleTapZoomDuration(100) longImg.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_CROP) longImg.setDoubleTapZoomDpi(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) longImg.setImage(ImageSource.uri(uri), ImageViewState(0f, PointF(0f, 0f), 0)) } } ================================================ FILE: matisse/src/main/java/com/matisse/ui/view/PreviewItemFragment.kt ================================================ package com.matisse.ui.view import android.content.Intent import android.graphics.Point import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment import com.matisse.R import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.utils.PhotoMetadataUtils import it.sephiroth.android.library.imagezoom.ImageViewTouch import it.sephiroth.android.library.imagezoom.ImageViewTouchBase /** * desc:预览界面真正载体
* time: 2018/9/6-9:40
* author:Leo
* since V 1.8.0
*/ class PreviewItemFragment : Fragment() { companion object { private const val ARGS_ITEM = "args_item" fun newInstance(item: Item): PreviewItemFragment { val fragment = PreviewItemFragment() val bundle = Bundle() bundle.putParcelable(ARGS_ITEM, item) fragment.arguments = bundle return fragment } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = inflater.inflate(R.layout.fragment_preview_item, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val item: Item = arguments!!.getParcelable(ARGS_ITEM) ?: return val videoPlayButton: View = view.findViewById(R.id.video_play_button) if (item.isVideo()) { videoPlayButton.visibility = View.VISIBLE videoPlayButton.setOnClickListener { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(item.getContentUri(), "video/*") if (intent.resolveActivity(activity!!.packageManager) != null) startActivity(intent) else Toast.makeText( context, R.string.error_no_video_activity, Toast.LENGTH_SHORT ).show() } } else { videoPlayButton.visibility = View.GONE } val image: ImageViewTouch = view.findViewById(R.id.image_view) image.displayType = ImageViewTouchBase.DisplayType.FIT_TO_SCREEN val size: Point = PhotoMetadataUtils.getBitmapSize(item.getContentUri(), activity) if (item.isGif()) { SelectionSpec.getInstance().imageEngine?.loadGifImage( context!!, size.x, size.y, image, item.getContentUri() ) } else { SelectionSpec.getInstance().imageEngine?.loadImage( context!!, size.x, size.y, image, item.getContentUri() ) } } fun resetView() { val image: ImageViewTouch? = view?.findViewById(R.id.image_view) image?.resetMatrix() } } ================================================ FILE: matisse/src/main/java/com/matisse/utils/BitmapUtils.kt ================================================ @file:JvmName("BitmapUtils") package com.matisse.utils import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.ExifInterface import android.net.Uri import java.io.IOException /** * 获取图片的旋转角度 * * @param path 图片绝对路径 * @return 图片的旋转角度 */ fun getBitmapDegree(path: String?): Int { if (path == null) return 0 var degree = 0 try { // 从指定路径下读取图片,并获取其EXIF信息 val exifInterface = ExifInterface(path) // 获取图片的旋转信息 val orientation = exifInterface.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL ) when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> degree = 90 ExifInterface.ORIENTATION_ROTATE_180 -> degree = 180 ExifInterface.ORIENTATION_ROTATE_270 -> degree = 270 } } catch (e: IOException) { e.printStackTrace() } return degree } // 通过uri加载图片 fun getBitmapFromUri(context: Context, uri: Uri, opts: BitmapFactory.Options): Bitmap? { return try { // mode:"r" 表示只读 "w"表示只写 val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") val fileDescriptor = parcelFileDescriptor?.fileDescriptor val image = BitmapFactory.decodeFileDescriptor(fileDescriptor, null, opts) parcelFileDescriptor?.close() image } catch (e: Exception) { null } } ================================================ FILE: matisse/src/main/java/com/matisse/utils/ExifInterfaceCompat.kt ================================================ package com.matisse.utils import android.media.ExifInterface /** * Created by liubo on 2018/9/6. */ object ExifInterfaceCompat { fun newInstance(fileName: String?): ExifInterface? { if (fileName == null) { throw NullPointerException("filename should not be null") } return ExifInterface(fileName) } } ================================================ FILE: matisse/src/main/java/com/matisse/utils/IntentUtils.kt ================================================ @file:JvmName("IntentUtils") package com.matisse.utils import android.app.Activity import android.content.Intent import android.net.Uri import com.matisse.entity.ConstValue import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.model.SelectedItemCollection import com.matisse.ucrop.UCrop import com.matisse.utils.Platform.aboveAndroidTen import java.io.File /** * 打开裁剪界面 */ fun gotoImageCrop(activity: Activity, selectedPath: ArrayList?) { if (selectedPath == null || selectedPath.isEmpty()) return startCrop(activity, selectedPath[0]) } /** * 去裁剪 * * @param originalPath */ fun startCrop(activity: Activity, originalPath: Uri) { val path = getPath(activity, originalPath) ?: "" val spec = SelectionSpec.getInstance() val options = UCrop.Options() .setCircleDimmedLayer(spec.isCircleCrop) .setDragFrameEnabled(true) .setCompressionQuality(50) .setFreeStyleCropEnabled(true) .setShowCropFrame(true) .setShowCropGrid(!spec.isCircleCrop) val isAndroidQ = aboveAndroidTen() val imgType = if (isAndroidQ) getLastImgSuffix(getMimeType(activity, originalPath)) else { getLastImgType(path) } val file = File( getDiskCacheDir(activity), getCreateFileName("IMG_") + imgType ) UCrop.of(originalPath, Uri.fromFile(file)) .withAspectRatio(1f, 1f) .withOptions(options) .start(activity) } /** * 处理预览界面提交返回选中结果 * @param originalEnable 是否原图 * @param selectedItems 选中的资源Item */ fun handleIntentFromPreview( activity: Activity, originalEnable: Boolean, selectedItems: List? ) { if (selectedItems == null) return val selectedUris = arrayListOf() val selectedId = arrayListOf() selectedItems.forEach { selectedUris.add(it.getContentUri()) selectedId.add(it.id.toString()) } finishIntentToMain( activity, selectedUris, selectedId, originalEnable ) } /** * 处理预览界面提交返回选中结果 * @param selectedUris 选中的资源uri * @param selectedId 选中的资源id */ private fun finishIntentToMain( activity: Activity, selectedUris: ArrayList, selectedId: ArrayList, originalEnable: Boolean ) { Intent().apply { putParcelableArrayListExtra(ConstValue.EXTRA_RESULT_SELECTION, selectedUris) putStringArrayListExtra(ConstValue.EXTRA_RESULT_SELECTION_ID, selectedId) putExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, originalEnable) activity.setResult(Activity.RESULT_OK, this) } activity.finish() } /** * 裁剪完成返回裁剪结果 * @param cropUri 需裁剪的图片路径 */ fun finishIntentFromCrop(activity: Activity, cropUri: Uri?) { cropUri?.run { Intent().apply { putParcelableArrayListExtra(ConstValue.EXTRA_RESULT_SELECTION, arrayListOf(cropUri)) activity.setResult(Activity.RESULT_OK, this) activity.finish() } } } /** * 预览界面提交或者返回时的Intent */ fun finishIntentFromPreviewApply( activity: Activity, apply: Boolean, selectedCollection: SelectedItemCollection, originalEnable: Boolean ) { Intent().apply { putExtra(ConstValue.EXTRA_RESULT_BUNDLE, selectedCollection.getDataWithBundle()) putExtra(ConstValue.EXTRA_RESULT_APPLY, apply) putExtra(ConstValue.EXTRA_RESULT_ORIGINAL_ENABLE, originalEnable) activity.setResult(Activity.RESULT_OK, this) } if (apply) activity.finish() } /** * 裁剪成功带回裁剪结果 */ fun finishIntentFromCropSuccess(activity: Activity, cropResultUri: Uri) { Intent().apply { putExtra(ConstValue.EXTRA_RESULT_CROP_BACK_BUNDLE, cropResultUri) activity.setResult(Activity.RESULT_OK, this) } activity.finish() } /** * 处理预览返回数据刷新 * @param isApplyData 正常返回/提交带回 true=提交带回 false=正常返回 */ fun handlePreviewIntent( activity: Activity, data: Intent?, originalEnable: Boolean, isApplyData: Boolean, selectedCollection: SelectedItemCollection ) { data?.apply { val resultBundle = getBundleExtra(ConstValue.EXTRA_RESULT_BUNDLE) resultBundle?.apply { val collectionType = getInt(ConstValue.STATE_COLLECTION_TYPE) val selected: ArrayList? = getParcelableArrayList(ConstValue.STATE_SELECTION) selected?.apply { if (isApplyData) { // 从预览界面确认提交过来 handleIntentFromPreview(activity, originalEnable, this) } else { // 从预览界面返回过来 selectedCollection.overwrite(this, collectionType) } } } } } ================================================ FILE: matisse/src/main/java/com/matisse/utils/ItemSelectUtils.kt ================================================ @file:JvmName("ItemSelectUtils") package com.matisse.utils import com.matisse.internal.entity.SelectionSpec import com.matisse.model.SelectedItemCollection /** * 返回选中图片中,超过原图大小上限的图片数量 * @param selectedCollection 资源选中操作类 */ fun countOverMaxSize(selectedCollection: SelectedItemCollection): Int { var count = 0 selectedCollection.asList().filter { it.isImage() }.forEach { val size = PhotoMetadataUtils.getSizeInMB(it.size) if (size > SelectionSpec.getInstance().originalMaxSize) count++ } return count } ================================================ FILE: matisse/src/main/java/com/matisse/utils/MediaStoreCompat.kt ================================================ package com.matisse.utils import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.MediaStore import androidx.fragment.app.Fragment import com.matisse.entity.CaptureStrategy import java.lang.ref.WeakReference class MediaStoreCompat { private var kContext: WeakReference private var kFragment: WeakReference? private var captureStrategy: CaptureStrategy? = null private var currentPhotoUri: Uri? = null private var currentPhotoPath: String? = null companion object { fun hasCameraFeature(context: Context): Boolean { val pm = context.applicationContext.packageManager return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) } } constructor(activity: Activity) : this(activity, null) constructor(activity: Activity, fragment: Fragment?) { kContext = WeakReference(activity) kFragment = if (fragment == null) null else WeakReference(fragment) } fun setCaptureStrategy(strategy: CaptureStrategy) { captureStrategy = strategy } fun dispatchCaptureIntent(context: Context, requestCode: Int) { val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) if (captureIntent.resolveActivity(context.packageManager) != null) { // 创建uri createCurrentPhotoUri() captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) captureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { val resInfoList = context.packageManager .queryIntentActivities(captureIntent, PackageManager.MATCH_DEFAULT_ONLY) for (resolveInfo in resInfoList) { val packageName = resolveInfo.activityInfo.packageName context.grantUriPermission( packageName, currentPhotoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION ) } } if (kFragment != null) { kFragment?.get()?.startActivityForResult(captureIntent, requestCode) } else { kContext.get()?.startActivityForResult(captureIntent, requestCode) } } } private fun createCurrentPhotoUri() { currentPhotoUri = if (Platform.beforeAndroidTen()) createImageFile( kContext.get()!!, captureStrategy?.authority ?: "" ) { currentPhotoPath = it } else createImageFileForQ(kContext.get()!!) { currentPhotoPath = getPath(kContext.get(), it) } } fun getCurrentPhotoUri() = currentPhotoUri fun getCurrentPhotoPath() = currentPhotoPath } ================================================ FILE: matisse/src/main/java/com/matisse/utils/PathUtils.kt ================================================ @file:JvmName("PathUtils") package com.matisse.utils import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore import android.text.TextUtils import androidx.core.content.FileProvider import java.io.File import java.text.SimpleDateFormat import java.util.* /** * Describe : http://stackoverflow.com/a/27271131/4739220 * Created by Leo on 2018/9/5 on 14:07. */ fun getSimpleDateFormat(): String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) fun getPath(context: Context?, uri: Uri?): String? { if (uri == null || context == null) return "" // DocumentProvider if (Platform.hasKitKat19() && DocumentsContract.isDocumentUri(context, uri)) { // ExternalStorageProvider if (isExternalStorageDocument(uri)) { val docId = DocumentsContract.getDocumentId(uri) val split = docId.split(":") val type = split[0] if ("primary".equals(type, true)) { return Environment.getExternalStorageDirectory().toString() + "/" + split[1] } } else if (isDownloadsDocument(uri)) { val id = DocumentsContract.getDocumentId(uri) val contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id) ) return getRealFilePath(context, contentUri, null, null) } else if (isMediaDocument(uri)) { val docId = DocumentsContract.getDocumentId(uri) val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val type = split[0] var contentUri: Uri? = null when (type) { "image" -> contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI "video" -> contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI "audio" -> contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } val selection = "_id=?" val selectionArgs = arrayOf(split[1]) return getRealFilePath(context, contentUri, selection, selectionArgs) } } else if ("content".equals(uri.scheme, true)) { return getRealFilePath(context, uri, null, null) } else if ("file".equals(uri.scheme, true)) { // File return uri.path } return null } /** * Get the value of the data column for this Uri. This is useful for * MediaStore Uris, and other file-based ContentProviders. * * @param context The context. * @param uri The Uri to query. * @param selection (Optional) Filter used in the query. * @param selectionArgs (Optional) Selection arguments used in the query. * @return The value of the _data column, which is typically a file path. */ fun getRealFilePath( context: Context, uri: Uri?, selection: String? = null, selectionArgs: Array? = null ): String? { if (null == uri) return null val scheme = uri.scheme var realPath: String? = "" when (scheme) { null, ContentResolver.SCHEME_FILE -> realPath = uri.path ContentResolver.SCHEME_CONTENT -> { context.contentResolver.query( uri, arrayOf(MediaStore.Images.ImageColumns.DATA), selection, selectionArgs, null )?.run { if (moveToFirst()) { val index = getColumnIndex(MediaStore.Images.ImageColumns.DATA) if (index > -1) realPath = getString(index) } close() } } } if (TextUtils.isEmpty(realPath)) { val uriString = uri.toString() val index = uriString.lastIndexOf("/") val imageName = uriString.substring(index) var storageDir = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_PICTURES ) val file = File(storageDir, imageName) if (file.exists()) { realPath = file.absolutePath } else { storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) realPath = File(storageDir, imageName).absolutePath } } return realPath } /** * @param uri The Uri to check. * @return Whether the Uri authority is ExternalStorageProvider. */ private fun isExternalStorageDocument(uri: Uri): Boolean { return "com.android.externalstorage.documents" == uri.authority } /** * @param uri The Uri to check. * @return Whether the Uri authority is DownloadsProvider. */ private fun isDownloadsDocument(uri: Uri): Boolean { return "com.android.providers.downloads.documents" == uri.authority } /** * @param uri The Uri to check. * @return Whether the Uri authority is MediaProvider. */ private fun isMediaDocument(uri: Uri): Boolean { return "com.android.providers.media.documents" == uri.authority } fun createImageFile( context: Context, authority: String, otherEvent: ((absolutePath: String) -> Unit)? = null ): Uri { val imageFileName = String.format("JPEG_%s.jpg", getSimpleDateFormat()) val storageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) if (!storageDir.exists()) storageDir.mkdirs() val tempFile = File(storageDir, imageFileName) otherEvent?.invoke(tempFile.absolutePath) return FileProvider.getUriForFile(context, authority, tempFile) } fun createImageFileForQ( context: Context, otherEvent: ((uri: Uri?) -> Unit)? = null ): Uri? { val imageFileName = String.format("JPEG_%s.jpg", getSimpleDateFormat()) val resolver = context.contentResolver val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, imageFileName) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) } val uri = resolver?.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) otherEvent?.invoke(uri) return uri } /** * 根据时间戳创建文件名 * * @param prefix 前缀名 * @return */ fun getCreateFileName(prefix: String): String { val millis = System.currentTimeMillis() return prefix + SimpleDateFormat("yyyyMMdd_HHmmssSS").format(millis) } /** * @param ctx * @return */ fun getDiskCacheDir(ctx: Context): String { return ctx.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.path } /** * 获取图片后缀 * * @param path * @return */ fun getLastImgType(path: String): String { try { val index = path.lastIndexOf(".") if (index > 0) { val imageType = path.substring(index) when (imageType) { ".png", ".PNG", ".jpg", ".jpeg", ".JPEG", ".WEBP", ".bmp", ".BMP", ".webp", ".gif", ".GIF" -> return imageType else -> return ".png" } } else { return ".png" } } catch (e: Exception) { e.printStackTrace() return ".png" } } /** * 获取图片后缀 * * @param mineType * @return */ fun getLastImgSuffix(mineType: String): String { val defaultSuffix = ".png" try { val index = mineType.lastIndexOf("/") + 1 if (index > 0) { return "." + mineType.substring(index) } } catch (e: Exception) { e.printStackTrace() return defaultSuffix } return defaultSuffix } /** * 根据uri获取MIME_TYPE * * @param uri * @return */ fun getMimeType(context: Context, uri: Uri): String { if (ContentResolver.SCHEME_CONTENT == uri.scheme) { val cursor = context.applicationContext.contentResolver.query( uri, arrayOf(MediaStore.Files.FileColumns.MIME_TYPE), null, null, null ) if (cursor != null) { if (cursor.moveToFirst()) { val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE) if (columnIndex > -1) { return cursor.getString(columnIndex) } } cursor.close() } } return "image/jpeg" } ================================================ FILE: matisse/src/main/java/com/matisse/utils/PhotoMetadataUtils.kt ================================================ package com.matisse.utils import android.app.Activity import android.content.ContentResolver import android.content.Context import android.database.Cursor import android.graphics.BitmapFactory import android.graphics.Point import android.media.ExifInterface import android.net.Uri import android.provider.MediaStore import android.util.DisplayMetrics import com.matisse.MimeTypeManager import com.matisse.R import com.matisse.entity.IncapableCause import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.text.DecimalFormat import java.text.NumberFormat import java.util.* import kotlin.math.log10 import kotlin.math.pow /** * Created by Leo on 2018/8/29 on 15:24. */ object PhotoMetadataUtils { private const val MAX_WIDTH = 1600 /** * 遍历外部自定义过滤器 */ fun isAcceptable(context: Context, item: Item?): IncapableCause? { if (!isSelectableType(context, item)) return IncapableCause(context.getString(R.string.error_file_type)) if (SelectionSpec.getInstance().filters != null) { SelectionSpec.getInstance().filters?.forEach { return it.filter(context, item) } } return null } private fun isSelectableType(context: Context?, item: Item?): Boolean { val mimeTypeSet = SelectionSpec.getInstance().mimeTypeSet if (context == null || mimeTypeSet == null) return false for (type in mimeTypeSet) { if (MimeTypeManager.checkType(context, item?.getContentUri(), type.getValue())) { return true } } return false } fun getSizeInMB(sizeInBytes: Long): Float { val df = NumberFormat.getNumberInstance(Locale.US) as DecimalFormat df.applyPattern("0.0") var result = df.format((sizeInBytes.toFloat() / 1024f / 1024f).toDouble()) result = result.replace(",".toRegex(), ".") // in some case , 0.0 will be 0,0 return java.lang.Float.valueOf(result) } fun getReadableFileSize(size: Long): String { if (size <= 0) return "0" val units = arrayOf("B", "KB", "MB", "GB", "TB") val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt() return DecimalFormat("#,##0.#") .format(size / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] } fun getBitmapSize(uri: Uri?, activity: Activity?): Point { val resolver = activity!!.contentResolver val imageSize = getBitmapBounds(resolver, uri!!) var w = imageSize.x var h = imageSize.y if (shouldRotate(activity, uri)) { w = imageSize.y h = imageSize.x } if (h == 0) return Point(MAX_WIDTH, MAX_WIDTH) val metrics = DisplayMetrics() activity.windowManager.defaultDisplay.getMetrics(metrics) val screenWidth = metrics.widthPixels val screenHeight = metrics.heightPixels val widthScale = screenWidth / w val heightScale = screenHeight / h if (widthScale > heightScale) return Point((w * widthScale), (h * heightScale)) return Point((w * widthScale), (h * heightScale)) } private fun shouldRotate(context: Context, uri: Uri): Boolean { val exif: ExifInterface? try { exif = ExifInterfaceCompat.newInstance(getPath(context, uri)) } catch (e: IOException) { return false } val orientation = exif!!.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1) return orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270 } private fun getBitmapBounds(resolver: ContentResolver?, uri: Uri): Point { var inStream: InputStream? = null try { val options = BitmapFactory.Options() options.inJustDecodeBounds = true inStream = resolver!!.openInputStream(uri) BitmapFactory.decodeStream(inStream, null, options) val width = options.outWidth val height = options.outHeight return Point(width, height) } catch (e: FileNotFoundException) { return Point(0, 0) } finally { if (inStream != null) { try { inStream.close() } catch (e: IOException) { e.printStackTrace() } } } } /** * 是否是长图 * * @param size * @return true 是 or false 不是 */ fun isLongImg(size: Point?): Boolean { if (null != size) { val width = size.x val height = size.y val h = width * 3 return height > h } return false } } ================================================ FILE: matisse/src/main/java/com/matisse/utils/Platform.kt ================================================ package com.matisse.utils import android.content.Context import android.content.pm.PackageManager import android.os.Build /** * Created by Leo on 2018/9/5 on 14:09. */ object Platform { fun hasKitKat19() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT fun hasKitO26() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O fun beforeAndroidTen() = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q fun aboveAndroidTen() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q fun getPackageName(context: Context?): String? { if (context == null) return "" val manager = context.packageManager try { val info = manager.getPackageInfo(context.packageName, 0) return info.packageName } catch (e: PackageManager.NameNotFoundException) { e.printStackTrace() } return "" } fun isClassExists(classFullName: String): Boolean { return try { Class.forName(classFullName) true } catch (e: ClassNotFoundException) { false } } } ================================================ FILE: matisse/src/main/java/com/matisse/utils/UIUtils.kt ================================================ @file:JvmName("UIUtils") package com.matisse.utils import android.content.Context import android.content.res.Resources import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.util.DisplayMetrics import android.util.TypedValue import android.util.TypedValue.applyDimension import android.view.View import android.view.WindowManager import android.widget.TextView import android.widget.Toast import androidx.fragment.app.FragmentActivity import com.matisse.R import com.matisse.entity.IncapableCause import com.matisse.widget.IncapableDialog import kotlin.math.min import kotlin.math.roundToInt const val MIN_GRID_WIDTH = 200 // min width of media grid const val MAX_SPAN_COUNT = 6 // max span of media grid fun handleCause(context: Context, cause: IncapableCause?) { if (cause?.noticeEvent != null) { cause.noticeEvent?.invoke( context, cause.form, cause.title ?: "", cause.message ?: "" ) return } when (cause?.form) { IncapableCause.DIALOG -> { val incapableDialog = IncapableDialog.newInstance(cause.title, cause.message) incapableDialog.show( (context as FragmentActivity).supportFragmentManager, IncapableDialog::class.java.name ) } IncapableCause.TOAST -> { Toast.makeText(context, cause.message, Toast.LENGTH_SHORT).show() } } } fun spanCount(context: Context, gridExpectedSize: Int): Int { if (gridExpectedSize < MIN_GRID_WIDTH) { return MAX_SPAN_COUNT } val screenWidth = context.resources.displayMetrics.widthPixels val expected = screenWidth / gridExpectedSize var spanCount = expected.toFloat().roundToInt() spanCount = min(spanCount, MAX_SPAN_COUNT) if (spanCount == 0) spanCount = 1 return spanCount } fun setTextDrawable(context: Context, textView: TextView?, attr: Int) { if (textView == null) return val drawables = textView.compoundDrawables val ta = context.theme.obtainStyledAttributes(intArrayOf(attr)) val color = ta.getColor(0, 0) ta.recycle() for (i in drawables.indices) { val drawable = drawables[i] if (drawable != null) { val state = drawable.constantState ?: continue drawables[i] = state.newDrawable().mutate().apply { colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) bounds = drawable.bounds } } } textView.setCompoundDrawables(drawables[0], drawables[1], drawables[2], drawables[3]) } /** * 根据attr获取外部文字资源 */ fun obtainAttrString(context: Context, attr: Int, defaultRes: Int = R.string.button_null): Int { val ta = context.theme.obtainStyledAttributes(intArrayOf(attr)) ?: return defaultRes val stringRes = ta.getResourceId(0, defaultRes) ta.recycle() return stringRes } /** * 设置控件显示隐藏 * 避免控件重复设置,统一提前添加判断 * * @param isVisible true visible * @param view targetView */ fun setViewVisible(isVisible: Boolean, view: View?) { if (view == null) return val visibleFlag = if (isVisible) View.VISIBLE else View.GONE if (view.visibility != visibleFlag) { view.visibility = visibleFlag } } fun dp2px(context: Context, dipValue: Float): Float { val mDisplayMetrics = getDisplayMetrics(context) return applyDimension(TypedValue.COMPLEX_UNIT_DIP, dipValue, mDisplayMetrics) } /** * 获取屏幕尺寸与密度. * @param context the context * @return mDisplayMetrics */ private fun getDisplayMetrics(context: Context?): DisplayMetrics { val mResources: Resources = if (context == null) { Resources.getSystem() } else { context.resources } return mResources.displayMetrics } /** * 获取屏幕的宽度px * * @param context 上下文 * @return 屏幕宽px */ fun getScreenWidth(context: Context): Int { val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val outMetrics = DisplayMetrics()// 创建了一张白纸 windowManager.defaultDisplay.getMetrics(outMetrics)// 给白纸设置宽高 return outMetrics.widthPixels } /** * 获取屏幕的高度px * @param context 上下文 * @return 屏幕高px */ fun getScreenHeight(context: Context): Int { val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val outMetrics = DisplayMetrics()// 创建了一张白纸 windowManager.defaultDisplay.getMetrics(outMetrics)// 给白纸设置宽高 return outMetrics.heightPixels } fun setOnClickListener(clickListener: View.OnClickListener, vararg view: View) { view.forEach { it.setOnClickListener(clickListener) } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/CheckRadioView.kt ================================================ package com.matisse.widget import android.content.Context import android.content.res.TypedArray import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.graphics.drawable.Drawable import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.res.ResourcesCompat import com.matisse.R class CheckRadioView : AppCompatImageView { private var mDrawable: Drawable? = null private lateinit var selectedColorFilter: PorterDuffColorFilter private lateinit var unSelectUdColorFilter: PorterDuffColorFilter constructor(context: Context) : this(context, null) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init() } private fun init() { val ta: TypedArray = context?.theme ?.obtainStyledAttributes(intArrayOf(R.attr.Item_checkRadio)) ?: return val selectedColor = ta.getColor( 0, ResourcesCompat.getColor( resources, R.color.selector_base_text, context.theme ) ) val unSelectUdColor = ResourcesCompat.getColor( resources, R.color.check_original_radio_disable, context.theme ) ta.recycle() selectedColorFilter = PorterDuffColorFilter(selectedColor, PorterDuff.Mode.SRC_IN) unSelectUdColorFilter = PorterDuffColorFilter(unSelectUdColor, PorterDuff.Mode.SRC_IN) setChecked(false) } fun setChecked(enable: Boolean) { if (enable) { setImageResource(R.drawable.ic_preview_radio_on) mDrawable = drawable mDrawable?.colorFilter = selectedColorFilter } else { setImageResource(R.drawable.ic_preview_radio_off) mDrawable = drawable mDrawable?.colorFilter = unSelectUdColorFilter } } fun setColor(color: Int) { if (mDrawable == null) { mDrawable = drawable } mDrawable?.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/CheckView.kt ================================================ package com.matisse.widget import android.content.Context import android.content.res.TypedArray import android.graphics.* import android.graphics.drawable.Drawable import android.text.TextPaint import android.util.AttributeSet import android.view.View import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import com.matisse.R /** * Created by liubo on 2018/9/4. */ class CheckView : View { companion object { const val UNCHECKED = Integer.MIN_VALUE private const val STROKE_WIDTH = 3.0f // 圆环宽度 private const val SHADOW_WIDTH = 6.0f // 阴影宽度 private const val SIZE = 30 private const val STROKE_RADIUS = 11.5f private const val BG_RADIUS = 11.0f private const val CONTENT_SIZE = 16 } private var countable = false private var checked = false private var checkedNum = 0 private var strokePaint: Paint? = null private var backgroundPaint: Paint? = null private var textPaint: Paint? = null private var shadowPaint: Paint? = null private var checkDrawable: Drawable? = null private var kdensity = 0f private var checkRect: Rect? = null private var enable = true private var halfDensitySize = 0f constructor(context: Context?) : this(context, null, 0) constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) { initParams() } private fun initParams() { kdensity = context.resources?.displayMetrics?.density ?: 0f halfDensitySize = kdensity * SIZE / 2f strokePaint = Paint().run { isAntiAlias = true style = Paint.Style.STROKE xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER) strokeWidth = STROKE_WIDTH * kdensity this } val ta: TypedArray = context.theme.obtainStyledAttributes(intArrayOf(R.attr.Item_checkCircle_borderColor)) val defaultColor = ResourcesCompat.getColor( context.resources, R.color.item_checkCircle_borderColor, context.theme ) val color = ta.getColor(0, defaultColor) ta.recycle() strokePaint?.color = color checkDrawable = ResourcesCompat.getDrawable( context.resources, R.drawable.ic_check_white_18dp, context.theme ) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val sizeSpec = MeasureSpec.makeMeasureSpec((kdensity * SIZE).toInt(), MeasureSpec.EXACTLY) super.onMeasure(sizeSpec, sizeSpec) } fun setEnable(enable: Boolean) { if (this.enable != enable) { this.enable = enable invalidate() } } fun setCountable(boolean: Boolean) { if (countable != boolean) { countable = boolean invalidate() } } fun setChecked(boolean: Boolean) { if (countable) { throw IllegalStateException("CheckView is countable, call setCheckedNum() instead.") } checked = boolean invalidate() } fun setCheckedNum(num: Int) { if (!countable) { throw IllegalStateException("CheckView is not countable, call setChecked() instead.") } if (num != UNCHECKED && num < 0) { throw IllegalStateException("the num can't be negative") } checkedNum = num invalidate() } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) // draw outer and inner shadow initShadowPaint() shadowPaint?.apply { canvas?.drawCircle( halfDensitySize, halfDensitySize, kdensity.times(STROKE_RADIUS + STROKE_WIDTH / 2 + SHADOW_WIDTH), this ) } // draw white stroke strokePaint?.apply { canvas?.drawCircle( halfDensitySize, halfDensitySize, kdensity.times(STROKE_RADIUS), this ) } // draw content if (countable) { if (checkedNum != UNCHECKED) { initBackgroundPaint() backgroundPaint?.apply { canvas?.drawCircle( halfDensitySize, halfDensitySize, kdensity.times(BG_RADIUS), this ) } initTextPaint() textPaint?.apply { val text = checkedNum.toString() val baseX = (width - measureText(text)) / 2 val baseY = (height - descent() - ascent()) / 2 canvas?.drawText(text, baseX, baseY, this) } } } else { if (checked) { initBackgroundPaint() backgroundPaint?.apply { canvas?.drawCircle( halfDensitySize, halfDensitySize, BG_RADIUS * kdensity, this ) } if (canvas != null) { checkDrawable?.bounds = getCheckRect() checkDrawable?.draw(canvas) } } } alpha = if (enable) 1.0f else 0.5f } private fun getCheckRect(): Rect { if (checkRect == null) { val rectPadding = (halfDensitySize - CONTENT_SIZE * kdensity / 2).toInt() checkRect = Rect( rectPadding, rectPadding, (SIZE * kdensity - rectPadding).toInt(), (SIZE * kdensity - rectPadding).toInt() ) } return checkRect!! } private fun initTextPaint() { if (textPaint == null) { textPaint = TextPaint().run { isAntiAlias = true color = Color.WHITE typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) textSize = 12.0f * kdensity this } } } private fun initBackgroundPaint() { if (backgroundPaint == null) { backgroundPaint = Paint() backgroundPaint?.isAntiAlias = true backgroundPaint?.style = Paint.Style.FILL val ta: TypedArray = context.theme.obtainStyledAttributes(intArrayOf(R.attr.Item_checkCircle_bgColor)) val defaultColor = ResourcesCompat.getColor( context.resources, R.color.selector_base_text, context.theme ) val color = ta.getColor(0, defaultColor) ta.recycle() backgroundPaint?.color = color } } private fun initShadowPaint() { if (shadowPaint == null) { shadowPaint = Paint() shadowPaint?.isAntiAlias = true val outerRadius: Float = STROKE_RADIUS + STROKE_WIDTH / 2 val innerRadius = outerRadius - STROKE_WIDTH val gradientRadius = outerRadius + SHADOW_WIDTH val stop0 = (innerRadius - STROKE_WIDTH) / gradientRadius val stop1 = innerRadius / gradientRadius val stop2 = outerRadius / gradientRadius val stop3 = 1f val shadow = ContextCompat.getColor(context, R.color.shadow) val shadowHint = ContextCompat.getColor(context, R.color.shadow_hint) shadowPaint?.shader = (RadialGradient( halfDensitySize, halfDensitySize, kdensity.times(gradientRadius), intArrayOf(shadowHint, shadow, shadow, shadowHint), floatArrayOf(stop0, stop1, stop2, stop3), Shader.TileMode.CLAMP )) } } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/IncapableDialog.kt ================================================ package com.matisse.widget import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import com.matisse.R class IncapableDialog : DialogFragment() { companion object { private const val EXTRA_TITLE = "extra_title" private const val EXTRA_MESSAGE = "extra_message" fun newInstance(title: String?, message: String?): IncapableDialog { val dialog = IncapableDialog() val bundle = Bundle() bundle.putString(EXTRA_TITLE, title) bundle.putString(EXTRA_MESSAGE, message) dialog.arguments = bundle return dialog } } override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { val title = arguments?.getString(EXTRA_TITLE) ?: "" val message = arguments?.getString(EXTRA_MESSAGE) ?: "" val builder = activity?.let { AlertDialog.Builder(it) } if (title.isNotEmpty()) builder?.setTitle(title) if (message.isNotEmpty()) { builder?.setMessage(message) } builder?.setPositiveButton(R.string.button_ok) { dialog, _ -> dialog?.dismiss() } return builder?.create()!! } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/MediaGrid.kt ================================================ package com.matisse.widget import android.content.Context import android.graphics.drawable.Drawable import android.text.format.DateUtils import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.ImageView import androidx.recyclerview.widget.RecyclerView import com.matisse.R import com.matisse.entity.Item import com.matisse.internal.entity.SelectionSpec import com.matisse.utils.setViewVisible import kotlinx.android.synthetic.main.view_media_grid_content.view.* class MediaGrid : SquareFrameLayout, View.OnClickListener { private lateinit var media: Item private lateinit var preBindInfo: PreBindInfo lateinit var listener: OnMediaGridClickListener constructor(context: Context?) : this(context, null, 0) constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) { LayoutInflater.from(context).inflate(R.layout.view_media_grid_content, this, true) media_thumbnail.setOnClickListener(this) check_view.setOnClickListener(this) } override fun onClick(v: View?) { when (v) { media_thumbnail -> listener.onThumbnailClicked( media_thumbnail, media, preBindInfo.viewHolder ) check_view -> listener.onCheckViewClicked(check_view, media, preBindInfo.viewHolder) } } fun preBindMedia(info: PreBindInfo) { preBindInfo = info } fun bindMedia(item: Item) { media = item setGifTag() initCheckView() setImage() setVideoDuration() } private fun setGifTag() { setViewVisible(media.isGif(), gif) } private fun initCheckView() { check_view.setCountable(preBindInfo.checkViewCountable) } fun setCheckedNum(checkedNum: Int) { check_view.setCheckedNum(checkedNum) } fun setChecked(checked: Boolean) { check_view.setChecked(checked) } private fun setImage() { if (media.isGif()) { SelectionSpec.getInstance().imageEngine?.loadGifThumbnail( context, preBindInfo.resize, preBindInfo.placeholder, media_thumbnail, media.getContentUri() ) } else { SelectionSpec.getInstance().imageEngine?.loadThumbnail( context, preBindInfo.resize, preBindInfo.placeholder, media_thumbnail, media.getContentUri() ) } } private fun setVideoDuration() { if (media.isVideo()) { setViewVisible(true, video_duration) video_duration.text = DateUtils.formatElapsedTime(media.duration / 1000) } else { setViewVisible(false, video_duration) } } interface OnMediaGridClickListener { fun onThumbnailClicked(thumbnail: ImageView, item: Item, holder: RecyclerView.ViewHolder) fun onCheckViewClicked(checkView: CheckView, item: Item, holder: RecyclerView.ViewHolder) } class PreBindInfo( var resize: Int, var placeholder: Drawable?, var checkViewCountable: Boolean, var viewHolder: RecyclerView.ViewHolder ) } ================================================ FILE: matisse/src/main/java/com/matisse/widget/MediaGridInset.kt ================================================ package com.matisse.widget import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView class MediaGridInset( private var spanCount: Int, private var spacing: Int, private var includeEdge: Boolean ) : RecyclerView.ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { val position = parent.getChildAdapterPosition(view) val column = position % spanCount outRect.apply { if (includeEdge) { left = spacing - column * spacing / spanCount right = (column + 1) * spacing / spanCount if (position < spanCount) top = spacing bottom = spacing } else { left = column * spacing / spanCount right = spacing - (column + 1) * spacing / spanCount if (position >= spanCount) top = spacing } } } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/PreviewViewPager.kt ================================================ package com.matisse.widget import android.content.Context import android.util.AttributeSet import android.view.View import androidx.viewpager.widget.ViewPager import it.sephiroth.android.library.imagezoom.ImageViewTouch /** * Created by liubo on 2018/9/10. */ class PreviewViewPager(context: Context, attributes: AttributeSet) : ViewPager(context, attributes) { override fun canScroll(v: View?, checkV: Boolean, dx: Int, x: Int, y: Int): Boolean { if (v is ImageViewTouch) { return v.canScroll(dx) || super.canScroll(v, checkV, dx, x, y) } return super.canScroll(v, checkV, dx, x, y) } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/SquareFrameLayout.kt ================================================ package com.matisse.widget import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout open class SquareFrameLayout : FrameLayout { constructor(context: Context?) : this(context, null, 0) constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( context!!, attrs, defStyleAttr ) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, widthMeasureSpec) } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/CompatDecoderFactory.java ================================================ package com.matisse.widget.longimage; import androidx.annotation.NonNull; /** * Compatibility factory to instantiate decoders with empty public constructors. * @param The base type of the decoder this factory will produce. */ public class CompatDecoderFactory implements DecoderFactory { private Class clazz; public CompatDecoderFactory(@NonNull Class clazz) { this.clazz = clazz; } @Override public T make() throws IllegalAccessException, InstantiationException { return clazz.newInstance(); } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/DecoderFactory.java ================================================ package com.matisse.widget.longimage; /** * Interface for decoder (and region decoder) factories. * @param the class of decoder that will be produced. */ public interface DecoderFactory { /** * Produce a new instance of a decoder with type {@link T}. * @return a new instance of your decoder. */ T make() throws IllegalAccessException, InstantiationException; } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/ImageDecoder.java ================================================ package com.matisse.widget.longimage; import android.content.Context; import android.graphics.Bitmap; import android.net.Uri; /** * 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 ImageDecoder { /** * Decode an image. When possible, initial setup work once in this method. This method * must return the dimensions of the image. The URI can be in one of the following formats: * File: file:///scard/picture.jpg * Asset: file:///android_asset/picture.png * Resource: android.resource://com.example.app/drawable/picture * @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. */ Bitmap decode(Context context, Uri uri) throws Exception; } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/ImageRegionDecoder.java ================================================ package com.matisse.widget.longimage; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Point; import android.graphics.Rect; import android.net.Uri; /** * 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, initial setup work once in this method. This method * must return the dimensions of the image. The URI can be in one of the following formats: * File: file:///scard/picture.jpg * Asset: file:///android_asset/picture.png * Resource: android.resource://com.example.app/drawable/picture * @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. */ Point init(Context context, Uri uri) throws Exception; /** * 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 an {@link android.os.AsyncTask} running in a single * threaded executor, and while a synchronization lock is held on this object, so will never be called concurrently * even if the decoder implementation supports it. * @param sRect Source image rectangle to decode. * @param sampleSize Sample size. * @return The decoded region. It is safe to return null if decoding fails. */ Bitmap decodeRegion(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(); } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/ImageSource.java ================================================ package com.matisse.widget.longimage; import android.graphics.Bitmap; import android.graphics.Rect; import android.net.Uri; 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. * * 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. */ public final class ImageSource { static final String FILE_SCHEME = "file:///"; 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(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. */ public static ImageSource resource(int resId) { return new ImageSource(resId); } /** * Create an instance from an asset name. * @param assetName asset name. */ public static ImageSource asset(String assetName) { 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. */ public static ImageSource uri(String uri) { 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. */ public static ImageSource uri(Uri uri) { 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. */ public static ImageSource bitmap(Bitmap bitmap) { 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. */ public static ImageSource cachedBitmap(Bitmap bitmap) { 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. */ 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. */ 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. * @return this instance for chaining. */ 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. * @return this instance for chaining. */ 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. * @return this instance for chaining. */ 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(); } } protected final Uri getUri() { return uri; } protected final Bitmap getBitmap() { return bitmap; } protected final Integer getResource() { return resource; } protected final boolean getTile() { return tile; } protected final int getSWidth() { return sWidth; } protected final int getSHeight() { return sHeight; } protected final Rect getSRegion() { return sRegion; } protected final boolean isCached() { return cached; } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/ImageViewState.java ================================================ /* Copyright 2014 David Morrissey 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 com.matisse.widget.longimage; import android.graphics.PointF; import java.io.Serializable; /** * Wraps the scale, center and orientation of a displayed image for easy restoration on screen rotate. */ public class ImageViewState implements Serializable { private float scale; private float centerX; private float centerY; private int orientation; public ImageViewState(float scale, PointF center, int orientation) { this.scale = scale; this.centerX = center.x; this.centerY = center.y; this.orientation = orientation; } public float getScale() { return scale; } public PointF getCenter() { return new PointF(centerX, centerY); } public int getOrientation() { return orientation; } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/SkiaImageDecoder.java ================================================ package com.matisse.widget.longimage; 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 java.io.InputStream; import java.util.List; /** * Default implementation of {@link com.davemorrissey.labs.subscaleview.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 + "://"; @Override public Bitmap decode(Context context, Uri uri) throws Exception { String uriString = uri.toString(); BitmapFactory.Options options = new BitmapFactory.Options(); Bitmap bitmap; options.inPreferredConfig = Bitmap.Config.RGB_565; 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 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) { } } } } if (bitmap == null) { throw new RuntimeException("Skia image region decoder returned null bitmap - image format may not be supported"); } return bitmap; } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/SkiaImageRegionDecoder.java ================================================ package com.matisse.widget.longimage; 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.Bitmap.Config; 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 java.io.InputStream; import java.util.List; /** * Default implementation of {@link com.davemorrissey.labs.subscaleview.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. */ public class SkiaImageRegionDecoder implements ImageRegionDecoder { private BitmapRegionDecoder decoder; private final Object decoderLock = new Object(); 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 + "://"; @Override public Point init(Context context, 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 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); decoder = BitmapRegionDecoder.newInstance(inputStream, false); } finally { if (inputStream != null) { try { inputStream.close(); } catch (Exception e) { } } } } return new Point(decoder.getWidth(), decoder.getHeight()); } @Override public Bitmap decodeRegion(Rect sRect, int sampleSize) { synchronized (decoderLock) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inSampleSize = sampleSize; options.inPreferredConfig = Config.RGB_565; 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; } } @Override public boolean isReady() { return decoder != null && !decoder.isRecycled(); } @Override public void recycle() { decoder.recycle(); } } ================================================ FILE: matisse/src/main/java/com/matisse/widget/longimage/SubsamplingScaleImageView.java ================================================ /* Copyright 2013-2015 David Morrissey 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 com.matisse.widget.longimage; 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.media.ExifInterface; import android.net.Uri; import android.os.AsyncTask; import android.os.Build.VERSION; import android.os.Handler; import android.os.Message; import android.provider.MediaStore; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.Log; 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 com.matisse.R; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.Method; 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; /** * Displays an image subsampled as necessary to avoid loading too much image data into memory. After a pinch to zoom in, * a set of image tiles subsampled at higher resolution are loaded and displayed over the base layer. During pinch and * zoom, tiles off screen or higher/lower resolution than required are discarded from memory. * * Tiles are no larger than the max supported bitmap size, so with large images tiling may be used even when zoomed out. * * v prefixes - coordinates, translations and distances measured in screen (view) pixels * s prefixes - coordinates, translations and distances measured in source image pixels (scaled) */ @SuppressWarnings("unused") public class SubsamplingScaleImageView extends View { private static final String TAG = SubsamplingScaleImageView.class.getSimpleName(); /** 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; private static final List VALID_ORIENTATIONS = Arrays.asList(ORIENTATION_0, ORIENTATION_90, ORIENTATION_180, ORIENTATION_270, ORIENTATION_USE_EXIF); /** 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; private static final List VALID_ZOOM_STYLES = Arrays.asList(ZOOM_FOCUS_FIXED, ZOOM_FOCUS_CENTER, ZOOM_FOCUS_CENTER_IMMEDIATE); /** 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; private static final List VALID_EASING_STYLES = Arrays.asList(EASE_IN_OUT_QUAD, EASE_OUT_QUAD); /** 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; private static final List VALID_PAN_LIMITS = Arrays.asList(PAN_LIMIT_INSIDE, PAN_LIMIT_OUTSIDE, PAN_LIMIT_CENTER); /** 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; private static final List VALID_SCALE_TYPES = Arrays.asList(SCALE_TYPE_CENTER_CROP, SCALE_TYPE_CENTER_INSIDE, SCALE_TYPE_CUSTOM); /** 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; // Bitmap (preview or full image) private Bitmap bitmap; // 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> 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; // Min scale allowed (prevent infinite zoom) private float minScale = minScale(); // 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; // overrides for the dimensions of the generated tiles public static int TILE_SIZE_AUTO = Integer.MAX_VALUE; private int maxTileWidth = TILE_SIZE_AUTO; private int maxTileHeight = TILE_SIZE_AUTO; // Whether to use the thread pool executor to load tiles private boolean parallelLoadingEnabled; // 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; // Tile and image decoding private ImageRegionDecoder decoder; private final Object decoderLock = new Object(); private DecoderFactory bitmapDecoderFactory = new CompatDecoderFactory(SkiaImageDecoder.class); private DecoderFactory regionDecoderFactory = new CompatDecoderFactory(SkiaImageRegionDecoder.class); // Debug values private PointF vCenterStart; private float vDistStart; // Current quickscale state private final float quickScaleThreshold; 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; // Long click handler private Handler handler; private static final int MESSAGE_LONG_CLICK = 1; // Paint objects created once and reused for efficiency private Paint bitmapPaint; private Paint debugPaint; private Paint tileBgPaint; // Volatile fields used to reduce object creation private ScaleAndTranslate satTemp; private Matrix matrix; private RectF sRect; private float[] srcArray = new float[8]; private float[] dstArray = new float[8]; //The logical density of the display private float density; public SubsamplingScaleImageView(Context context, AttributeSet attr) { super(context, attr); density = getResources().getDisplayMetrics().density; setMinimumDpi(160); setDoubleTapZoomDpi(160); setGestureDetector(context); this.handler = new Handler(new Handler.Callback() { 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); } /** * 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. */ public final void setOrientation(int orientation) { if (!VALID_ORIENTATIONS.contains(orientation)) { throw new IllegalArgumentException("Invalid orientation: " + orientation); } this.orientation = orientation; reset(false); invalidate(); requestLayout(); } /** * Set the image source from a bitmap, resource, asset, file or other URI. * @param imageSource Image source. */ public final void setImage(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(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. * * 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(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. * * 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(ImageSource imageSource, ImageSource previewSource, ImageViewState state) { 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; if (decoder != null) { synchronized (decoderLock) { decoder.recycle(); decoder = null; } } 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> 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); } }); } /** * 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) { Log.w(TAG, "Error thrown by animation listener", e); } } anim = null; } // Abort if not ready if (vTranslate == null) { 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; 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(false); } } 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(false); 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); boolean atXEdge = lastX != vTranslate.x; boolean 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(false); } } } 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; 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) { // 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) { Log.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> 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> 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, debugPaint); } } else if (tile.loading && debug) { canvas.drawText("LOADING", tile.vRect.left + 5, tile.vRect.top + 35, debugPaint); } 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 + 5, tile.vRect.top + 15, debugPaint); } } } } } else if (bitmap != null) { 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), 5, 15, debugPaint); canvas.drawText("Translate: " + String.format(Locale.ENGLISH, "%.2f", vTranslate.x) + ":" + String.format(Locale.ENGLISH, "%.2f", vTranslate.y), 5, 35, debugPaint); PointF center = getCenter(); canvas.drawText("Source center: " + String.format(Locale.ENGLISH, "%.2f", center.x) + ":" + String.format(Locale.ENGLISH, "%.2f", center.y), 5, 55, debugPaint); debugPaint.setStrokeWidth(2f); if (anim != null) { PointF vCenterStart = sourceToViewCoord(anim.sCenterStart); PointF vCenterEndRequested = sourceToViewCoord(anim.sCenterEndRequested); PointF vCenterEnd = sourceToViewCoord(anim.sCenterEnd); canvas.drawCircle(vCenterStart.x, vCenterStart.y, 10, debugPaint); debugPaint.setColor(Color.RED); canvas.drawCircle(vCenterEndRequested.x, vCenterEndRequested.y, 20, debugPaint); debugPaint.setColor(Color.BLUE); canvas.drawCircle(vCenterEnd.x, vCenterEnd.y, 25, debugPaint); debugPaint.setColor(Color.CYAN); canvas.drawCircle(getWidth() / 2, getHeight() / 2, 30, debugPaint); } if (vCenterStart != null) { debugPaint.setColor(Color.RED); canvas.drawCircle(vCenterStart.x, vCenterStart.y, 20, debugPaint); } if (quickScaleSCenter != null) { debugPaint.setColor(Color.BLUE); canvas.drawCircle(sourceToViewX(quickScaleSCenter.x), sourceToViewY(quickScaleSCenter.y), 35, debugPaint); } if (quickScaleVStart != null) { debugPaint.setColor(Color.CYAN); canvas.drawCircle(quickScaleVStart.x, quickScaleVStart.y, 30, debugPaint); } debugPaint.setColor(Color.MAGENTA); debugPaint.setStrokeWidth(1f); } } /** * 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> 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 (debugPaint == null && debug) { debugPaint = new Paint(); debugPaint.setTextSize(18); debugPaint.setColor(Color.MAGENTA); debugPaint.setStyle(Style.STROKE); } } /** * 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(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 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> 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) { 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 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; } } } /** * Async task used to get image details without blocking the UI thread. */ private static class TilesInitTask extends AsyncTask { private final WeakReference viewRef; private final WeakReference contextRef; private final WeakReference> decoderFactoryRef; private final Uri source; private ImageRegionDecoder decoder; private Exception exception; TilesInitTask(SubsamplingScaleImageView view, Context context, DecoderFactory decoderFactory, Uri source) { this.viewRef = new WeakReference<>(view); this.contextRef = new WeakReference<>(context); this.decoderFactoryRef = new WeakReference>(decoderFactory); this.source = source; } @Override protected int[] doInBackground(Void... params) { try { String sourceUri = source.toString(); Context context = contextRef.get(); DecoderFactory 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) { sWidth = view.sRegion.width(); sHeight = view.sRegion.height(); } return new int[] { sWidth, sHeight, exifOrientation }; } } catch (Exception e) { Log.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); } } } } /** * 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(); } /** * Async task used to load images without blocking the UI thread. */ private static class TileLoadTask extends AsyncTask { private final WeakReference viewRef; private final WeakReference decoderRef; private final WeakReference 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); synchronized (view.decoderLock) { // 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 if (tile != null) { tile.loading = false; } } catch (Exception e) { Log.e(TAG, "Failed to decode tile", e); this.exception = e; } catch (OutOfMemoryError e) { Log.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); } } } } /** * 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(); } /** * Async task used to load bitmap without blocking the UI thread. */ private static class BitmapLoadTask extends AsyncTask { private final WeakReference viewRef; private final WeakReference contextRef; private final WeakReference> decoderFactoryRef; private final Uri source; private final boolean preview; private Bitmap bitmap; private Exception exception; BitmapLoadTask(SubsamplingScaleImageView view, Context context, DecoderFactory decoderFactory, Uri source, boolean preview) { this.viewRef = new WeakReference<>(view); this.contextRef = new WeakReference<>(context); this.decoderFactoryRef = new WeakReference>(decoderFactory); this.source = source; this.preview = preview; } @Override protected Integer doInBackground(Void... params) { try { String sourceUri = source.toString(); Context context = contextRef.get(); DecoderFactory 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) { Log.e(TAG, "Failed to load bitmap", e); this.exception = e; } catch (OutOfMemoryError e) { Log.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); } } } } } /** * 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 { Log.w(TAG, "Unsupported orientation: " + orientation); } } } } catch (Exception e) { Log.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 { Log.w(TAG, "Unsupported EXIF orientation: " + orientationAttr); } } catch (Exception e) { Log.w(TAG, "Could not get EXIF orientation of image"); } } return exifOrientation; } private void execute(AsyncTask asyncTask) { if (parallelLoadingEnabled && VERSION.SDK_INT >= 11) { try { Field executorField = AsyncTask.class.getField("THREAD_POOL_EXECUTOR"); Executor executor = (Executor)executorField.get(null); Method executeMethod = AsyncTask.class.getMethod("executeOnExecutor", Executor.class, Object[].class); executeMethod.invoke(asyncTask, executor, null); return; } catch (Exception e) { Log.i(TAG, "Failed to execute AsyncTask on thread pool executor, falling back to single threaded executor", e); } } asyncTask.execute(); } 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 ScaleAndTranslate(float scale, PointF vTranslate) { this.scale = scale; this.vTranslate = vTranslate; } private float scale; private PointF vTranslate; } /** * Set scale, center and orientation from saved state. */ private void restoreState(ImageViewState state) { if (state != null && state.getCenter() != 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; } /** * In SDK 14 and above, use canvas max bitmap width and height instead of the default 2048, to avoid redundant tiling. */ private Point getMaxBitmapDimensions(Canvas canvas) { int maxWidth = 2048; int maxHeight = 2048; if (VERSION.SDK_INT >= 14) { try { maxWidth = (Integer)Canvas.class.getMethod("getMaximumBitmapWidth").invoke(canvas); maxHeight = (Integer)Canvas.class.getMethod("getMaximumBitmapHeight").invoke(canvas); } catch (Exception e) { // Return default } } return new Point(Math.min(maxWidth, maxTileWidth), Math.min(maxHeight, 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; debugPaint = 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; } /** * Convert screen coordinate to source coordinate. */ public final PointF viewToSourceCoord(PointF vxy) { return viewToSourceCoord(vxy.x, vxy.y, new PointF()); } /** * Convert screen coordinate to source coordinate. */ public final PointF viewToSourceCoord(float vx, float vy) { return viewToSourceCoord(vx, vy, new PointF()); } /** * Convert screen coordinate to source coordinate. */ public final PointF viewToSourceCoord(PointF vxy, PointF sTarget) { return viewToSourceCoord(vxy.x, vxy.y, sTarget); } /** * Convert screen coordinate to source coordinate. */ public final PointF viewToSourceCoord(float vx, float vy, PointF sTarget) { if (vTranslate == null) { return null; } sTarget.set(viewToSourceX(vx), viewToSourceY(vy)); return sTarget; } /** * Convert source to screen x coordinate. */ private float sourceToViewX(float sx) { if (vTranslate == null) { return Float.NaN; } return (sx * scale) + vTranslate.x; } /** * Convert source to screen y coordinate. */ private float sourceToViewY(float sy) { if (vTranslate == null) { return Float.NaN; } return (sy * scale) + vTranslate.y; } /** * Convert source coordinate to screen coordinate. */ public final PointF sourceToViewCoord(PointF sxy) { return sourceToViewCoord(sxy.x, sxy.y, new PointF()); } /** * Convert source coordinate to screen coordinate. */ public final PointF sourceToViewCoord(float sx, float sy) { return sourceToViewCoord(sx, sy, new PointF()); } /** * Convert source coordinate to screen coordinate. */ public final PointF sourceToViewCoord(PointF sxy, PointF vTarget) { return sourceToViewCoord(sxy.x, sxy.y, vTarget); } /** * Convert source coordinate to screen coordinate. */ public final PointF sourceToViewCoord(float sx, float sy, PointF vTarget) { if (vTranslate == null) { return null; } vTarget.set(sourceToViewX(sx), sourceToViewY(sy)); return vTarget; } /** * Convert source rect to screen rect, integer values. */ private Rect sourceToViewRect(Rect sRect, Rect vTarget) { vTarget.set( (int)sourceToViewX(sRect.left), (int)sourceToViewY(sRect.top), (int)sourceToViewX(sRect.right), (int)sourceToViewY(sRect.bottom) ); return vTarget; } /** * 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. */ 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. */ private PointF limitedSCenter(float sCenterX, float sCenterY, float scale, 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) { 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) { Log.d(TAG, String.format(message, args)); } } /** * * 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(Class regionDecoderClass) { 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(DecoderFactory regionDecoderFactory) { 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(Class bitmapDecoderClass) { 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(DecoderFactory bitmapDecoderFactory) { if (bitmapDecoderFactory == null) { throw new IllegalArgumentException("Decoder factory cannot be set to null"); } this.bitmapDecoderFactory = bitmapDecoderFactory; } /** * Set the pan limiting style. See static fields. Normally {@link #PAN_LIMIT_INSIDE} is best, for image galleries. */ 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. */ 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(); } } /** * 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. */ public final void setMaxScale(float maxScale) { this.maxScale = maxScale; } /** * 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. */ public final void setMinScale(float minScale) { this.minScale = minScale; } /** * 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. */ public float getMaxScale() { return maxScale; } /** * Returns the minimum allowed scale. */ public final float getMinScale() { return 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. */ public final PointF getCenter() { int mX = getWidth()/2; int mY = getHeight()/2; return viewToSourceCoord(mX, mY); } /** * Returns the current scale value. */ 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, 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. */ 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. */ 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. */ public final boolean isImageLoaded() { return imageLoadedSent; } /** * Called once when the full size image or its base layer tiles have been loaded. */ protected void onImageLoaded() { } /** * Get source width, ignoring orientation. If {@link #getOrientation()} returns 90 or 270, you can use {@link #getSHeight()} * for the apparent width. */ 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. */ 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()}. */ public final int getOrientation() { return orientation; } /** * 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. */ 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. */ public final ImageViewState getState() { if (vTranslate != null && sWidth > 0 && sHeight > 0) { return new ImageViewState(getScale(), getCenter(), getOrientation()); } return null; } /** * Returns 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. */ public final void setZoomEnabled(boolean zoomEnabled) { this.zoomEnabled = zoomEnabled; } /** * Returns true if double tap & swipe to zoom is enabled. */ public final boolean isQuickScaleEnabled() { return quickScaleEnabled; } /** * Enable or disable double tap & swipe to zoom. */ public final void setQuickScaleEnabled(boolean quickScaleEnabled) { this.quickScaleEnabled = quickScaleEnabled; } /** * Returns 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. */ 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); } /** * Toggle parallel loading. When enabled, tiles are loaded using the thread pool executor available * in SDK 11+. In older versions this has no effect. Parallel loading may use more memory and there * is a possibility that it will make the tile loading unreliable, but it reduces the chances of * an app's background processes blocking loading. * @param parallelLoadingEnabled Whether to run AsyncTasks using a thread pool executor. */ public void setParallelLoadingEnabled(boolean parallelLoadingEnabled) { this.parallelLoadingEnabled = parallelLoadingEnabled; } /** * Enables visual debugging, showing tile boundaries and sizes. */ 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. */ public void setOnImageEventListener(OnImageEventListener onImageEventListener) { this.onImageEventListener = onImageEventListener; } /** * Add a listener for pan and zoom events. */ public void setOnStateChangedListener(OnStateChangedListener onStateChangedListener) { this.onStateChangedListener = onStateChangedListener; } private void sendStateChanged(float oldScale, PointF oldVTranslate, int origin) { if (onStateChangedListener != null) { if (scale != oldScale) { onStateChangedListener.onScaleChanged(scale, origin); } if (!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. */ 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. */ 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. * @return {@link AnimationBuilder} instance. Call {@link AnimationBuilder#start()} to start the anim. */ public AnimationBuilder animateScaleAndCenter(float scale, PointF sCenter) { if (!isReady()) { return null; } return new AnimationBuilder(scale, sCenter); } /** * 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. */ 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. */ 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. */ 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. */ 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. */ private AnimationBuilder withPanLimited(boolean panLimited) { this.panLimited = panLimited; return this; } /** * Only for internal use. Indicates what caused the animation. */ 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) { Log.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(); } } /** * 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. */ 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(); } /** * 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() { } } /** * An event listener, allowing subclasses and activities to be notified of significant events. */ 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(); } /** * 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() { } } /** * 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. */ 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); } /** * 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) { } } } ================================================ FILE: matisse/src/main/res/anim/bottom_down_out.xml ================================================ ================================================ FILE: matisse/src/main/res/anim/bottom_up_in.xml ================================================ ================================================ FILE: matisse/src/main/res/anim/ucrop_anim_fade_in.xml ================================================ ================================================ FILE: matisse/src/main/res/anim/ucrop_close.xml ================================================ ================================================ FILE: matisse/src/main/res/anim/ucrop_loader_circle_path.xml ================================================ ================================================ FILE: matisse/src/main/res/anim/ucrop_loader_circle_scale.xml ================================================ ================================================ FILE: matisse/src/main/res/color/selector_base_text.xml ================================================ ================================================ FILE: matisse/src/main/res/color/selector_black_text.xml ================================================ ================================================ FILE: matisse/src/main/res/color/selector_white_text.xml ================================================ ================================================ FILE: matisse/src/main/res/color/ucrop_scale_text_view_selector.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable/ucrop_gif_bg.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable/ucrop_oval_true.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable/ucrop_shadow_upside.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable/ucrop_vector_ic_crop.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable/ucrop_vector_loader.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable/ucrop_vector_loader_animated.xml ================================================ ================================================ FILE: matisse/src/main/res/drawable-xhdpi/transparent.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/activity_matisse.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/activity_media_preview.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/dialog_bottom_sheet.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/dialog_bottom_sheet_folder.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/fragment_media_selection.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/fragment_picture_preview_item.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/fragment_preview_item.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/include_view_bottom.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/include_view_navigation.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/item_album_folder.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/item_media_grid.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/item_photo_capture.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_activity_photobox.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_aspect_ratio.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_layout_rotate_wheel.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_layout_scale_wheel.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_picture_activity_multi_cutting.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_picture_gf_adapter_edit_list.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/ucrop_view.xml ================================================ ================================================ FILE: matisse/src/main/res/layout/view_media_grid_content.xml ================================================ ================================================ FILE: matisse/src/main/res/menu/ucrop_menu_activity.xml ================================================

================================================ FILE: matisse/src/main/res/values/attrs.xml ================================================ ================================================ FILE: matisse/src/main/res/values/colors.xml ================================================ #FFFFFF #77FFFFFF #361F1F1F #DE000000 #00000000 #0D000000 #4D000000 @color/black @color/black_press #424242 #61FFFFFF #FF6E40 #CC5833 #fff #000 #FF6E40 #fff #000 #808080 #000 #80ffffff #ffffff #8c000000 #4f212121 #999999 #b0567ba3 ================================================ FILE: matisse/src/main/res/values/colors_default.xml ================================================ @color/white @color/gray #1523A7 #771523A7 @color/white @color/black @color/white @color/selector_black_text @color/color_hint_text @color/selector_base_text @color/selector_black_text @color/color_hint_text @color/color_hint_text @color/selector_white_text #808080 ================================================ FILE: matisse/src/main/res/values/dimens.xml ================================================ 48dp 4dp 48dp 48dp 15sp 15sp 15sp 15sp 15sp 14sp 16sp 17sp 16dp 8dp 20dp 2dp 10dp 64dp 72dp 3dp 13sp 10sp 50dp 40dp 30dp 1dp 1dp 30dp 100dp 10dp ================================================ FILE: matisse/src/main/res/values/ids.xml ================================================ ================================================ FILE: matisse/src/main/res/values/long_attrs.xml ================================================ ================================================ FILE: matisse/src/main/res/values/strings.xml ================================================ All Media OK Original Sure Sure Preview Apply(%1$d) Back "" Complete %1$s(%2$d) %1$s M Camera No media yet You can only select up to %1$d media files You can only select up to %1$d media of image files You can only select up to %1$d media of video files Unsupported file type Can\'t select images and videos at the same time No App found supporting video preview Can\'t select the images larger than %1$d MB %1$d images over %2$d MB. Original will be unchecked Crop Failed Please select media resource album is empty Original Edit Photo Crop Both input and output Uri must be specified Therefore, override color resource (ucrop_color_toolbar_widget) in your app to make it work on pre-L devices gif ================================================ FILE: matisse/src/main/res/values/styles.xml ================================================ ================================================ FILE: matisse/src/main/res/values/values.xml ================================================ 1500 ================================================ FILE: matisse/src/main/res/values-zh/strings.xml ================================================ 全部 预览 使用(%1$d) 返回 我知道了 原图 确定 确定 完成 "" %1$s(%2$d) %1$s M 拍一张 还没有图片或视频 最多只能选择 %1$d 个文件 最多只能选择 %1$d 个图片文件 最多只能选择 %1$d 个视频文件 不支持的文件类型 不能同时选择图片和视频 没有支持视频预览的应用 "该照片大于 %1$d M,无法上传将取消勾选原图" "有 %1$d 张照片大于 %2$d M\n无法上传,将取消勾选原图" 裁剪失败 请选择媒体资源 相册为空 原始比例 裁剪 裁剪 动图 ================================================ FILE: matisse/src/test/java/com/matisse/ExampleUnitTest.java ================================================ package com.matisse; import org.junit.Test; import static org.junit.Assert.assertEquals; /** * Example local unit test, which will execute on the development machine (host). * * @see Testing documentation */ public class ExampleUnitTest { @Test public void addition_isCorrect() { assertEquals(4, 2 + 2); } } ================================================ FILE: settings.gradle ================================================ include ':app', ':matisse'