├── .gitignore ├── LICENSE ├── QFsolution.apk ├── Readme.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── demon │ │ └── qf_app │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── demon │ │ │ └── qf_app │ │ │ ├── App.java │ │ │ ├── Coroutine.kt │ │ │ ├── FileUtils.kt │ │ │ ├── GlideLoader.kt │ │ │ ├── ImgAdapter.kt │ │ │ ├── ImgBrowseActivity.kt │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_img_browse.xml │ │ ├── activity_main.xml │ │ └── item_img.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ └── file_paths.xml │ └── test │ └── java │ └── com │ └── demon │ └── qf_app │ └── ExampleUnitTest.kt ├── build.gradle ├── demo.gif ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── solution ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src └── main ├── AndroidManifest.xml ├── java └── com │ └── demon │ └── qfsolution │ ├── QFHelper.kt │ ├── activity │ ├── QFBigImgActivity.kt │ └── QFImgsActivity.kt │ ├── bean │ └── QFImgBean.kt │ ├── fragment │ └── QFGhostFragment.kt │ ├── list │ ├── HackyGridLayoutManager.kt │ ├── QFImgAdapter.kt │ └── SpacesItemDecoration.kt │ ├── loader │ ├── IQFImgLoader.kt │ └── QFImgLoader.kt │ ├── photoview │ ├── Compat.kt │ ├── CustomGestureDetector.kt │ ├── OnGestureListener.kt │ ├── OnMatrixChangedListener.kt │ ├── OnOutsidePhotoTapListener.kt │ ├── OnPhotoTapListener.kt │ ├── OnScaleChangedListener.kt │ ├── OnSingleFlingListener.kt │ ├── OnViewDragListener.kt │ ├── OnViewTapListener.kt │ ├── PhotoView.kt │ ├── PhotoViewAttacher.kt │ └── Util.kt │ └── utils │ ├── MimeType.kt │ └── QFileExt.kt └── res ├── drawable-hdpi ├── ic_qf_camera.png ├── ic_qf_checked.png └── ic_qf_img.png ├── drawable-xhdpi ├── ic_qf_camera.png ├── ic_qf_checked.png └── ic_qf_img.png ├── drawable-xxhdpi ├── ic_qf_camera.png ├── ic_qf_checked.png └── ic_qf_img.png ├── drawable ├── qf_btn_ok.xml └── qf_unchecked.xml ├── layout ├── activity_qf_big_img.xml ├── activity_qf_imgs.xml └── list_qf_img.xml ├── values-en └── strings.xml ├── values-ja └── strings.xml ├── values-ko └── strings.xml ├── values-zh-rTW └── strings.xml └── values ├── colors.xml ├── dimen.xml ├── strings.xml └── styles.xml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | .DS_Store 5 | /build 6 | /captures 7 | .externalNativeBuild 8 | .cxx 9 | local.properties 10 | app/libs/ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 DeMon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /QFsolution.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/QFsolution.apk -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## QFsolution - 适用于AndroidQ及以上的文件操作解决方案 2 | 3 | [![](https://jitpack.io/v/iDeMonnnnnn/QFsolution.svg)](https://jitpack.io/#iDeMonnnnnn/QFsolution) 4 | 5 | 1. **适用于AndroidQ的简易图片选择器。** 6 | 2. **基于协程的系统文件选择,相册选择,系统拍照,系统裁剪。** 7 | 3. **Uri转为File的究极解决方案。** 8 | 4. **最新已兼容至Android12** 9 | 5. **兼容```Intent.ACTION_OPEN_DOCUMENT_TREE```选择文件夹Uri获取后路径** 10 | 11 | ### 开始使用 12 | **使用详情可见[文档WIKI](https://github.com/iDeMonnnnnn/QFsolution/wiki)** 13 | 14 | #### 添加依赖 15 | ``` 16 | allprojects { 17 | repositories { 18 | maven { url "https://jitpack.io" } 19 | } 20 | } 21 | ``` 22 | 23 | [latest_version](https://github.com/iDeMonnnnnn/QFsolution/releases) 24 | ``` 25 | dependencies { 26 | implementation 'com.github.iDeMonnnnnn:QFsolution:1.2.5' 27 | } 28 | ``` 29 | 30 | #### 添加权限 31 | ``` 32 | 33 | 34 | 35 | 36 | 37 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | QFSolution 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 23 | 24 | 27 | -------------------------------------------------------------------------------- /app/src/test/java/com/demon/qf_app/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qf_app 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | ext { 3 | androidMinSdkVersion = 19 4 | androidTargetVersion = 31 5 | androidCompileVersion = 31 6 | appcompatVersion = "1.4.1" 7 | materialVersion = "1.6.1" 8 | ktxVersion = "1.7.0" 9 | coroutinesVersion = "1.5.2" 10 | constraintlayoutVersion = "2.0.4" 11 | lifecycle_version = "2.5.0-alpha06" 12 | } 13 | 14 | 15 | buildscript { 16 | ext.kotlin_version = "1.6.21" 17 | repositories { 18 | google() 19 | mavenCentral() 20 | maven { url "https://jitpack.io" } 21 | } 22 | dependencies { 23 | classpath 'com.android.tools.build:gradle:7.1.3' 24 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 25 | // NOTE: Do not place your application dependencies here; they belong 26 | // in the individual module build.gradle files 27 | } 28 | } 29 | 30 | allprojects { 31 | repositories { 32 | google() 33 | mavenCentral() 34 | maven { url "https://jitpack.io" } 35 | } 36 | } 37 | 38 | task clean(type: Delete) { 39 | delete rootProject.buildDir 40 | } -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/demo.gif -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Dec 17 16:06:51 CST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | include ':solution' 3 | rootProject.name = "QFsolution" -------------------------------------------------------------------------------- /solution/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /solution/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdkVersion androidCompileVersion 8 | 9 | defaultConfig { 10 | minSdkVersion androidMinSdkVersion 11 | targetSdkVersion androidTargetVersion 12 | } 13 | } 14 | 15 | dependencies { 16 | implementation "androidx.core:core-ktx:$ktxVersion" 17 | implementation "androidx.appcompat:appcompat:$appcompatVersion" 18 | implementation "com.google.android.material:material:$materialVersion" 19 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" 20 | } -------------------------------------------------------------------------------- /solution/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/consumer-rules.pro -------------------------------------------------------------------------------- /solution/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /solution/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 10 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/QFHelper.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution 2 | 3 | import android.Manifest 4 | import android.annotation.SuppressLint 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.pm.PackageManager 8 | import android.net.Uri 9 | import android.os.Build 10 | import android.widget.Toast 11 | import androidx.annotation.NonNull 12 | import androidx.fragment.app.Fragment 13 | import androidx.fragment.app.FragmentActivity 14 | import com.demon.qfsolution.activity.QFBigImgActivity 15 | import com.demon.qfsolution.activity.QFImgsActivity 16 | import com.demon.qfsolution.fragment.qfActivityForResult 17 | import com.demon.qfsolution.loader.IQFImgLoader 18 | import com.demon.qfsolution.loader.QFImgLoader 19 | import com.demon.qfsolution.utils.uriToFile 20 | import kotlinx.coroutines.suspendCancellableCoroutine 21 | 22 | /** 23 | * @author DeMon 24 | * Created on 2020/11/4. 25 | * E-mail idemon_liu@qq.com 26 | * Desc: 27 | */ 28 | @SuppressLint("StaticFieldLeak") 29 | object QFHelper { 30 | const val EXTRA_RESULT = "qf.result" 31 | const val EXTRA_IMG = "qf.img" 32 | const val EXTRA_RC_IB = 0x1024 33 | 34 | var loadNum = 30 35 | var maxNum = 1 36 | var isNeedGif = false 37 | var spanCount = 3 38 | 39 | var isNeedCamera = true 40 | var authorities: String = "fileProvider" 41 | 42 | lateinit var context: Context 43 | 44 | var isLog = false 45 | 46 | /** 47 | * @param context 提供一个全局的Context 48 | * @param authorities 设置FileProvider的authorities,默认"fileProvider" 49 | */ 50 | @JvmStatic 51 | @JvmOverloads 52 | fun init(@NonNull context: Context, isLog: Boolean = false, @NonNull authorities: String = "fileProvider") { 53 | this.context = context 54 | this.authorities = authorities 55 | this.isLog = isLog 56 | } 57 | 58 | /** 59 | * 初始化图片加载器 60 | * 参考示例代码中的[GlideLoader] 61 | */ 62 | @JvmStatic 63 | fun initImgLoader(@NonNull loader: IQFImgLoader) { 64 | QFImgLoader.getInstance().init(loader) 65 | } 66 | 67 | /** 68 | *@param authorities 设置FileProvider的authorities,默认"fileProvider" 69 | */ 70 | @JvmStatic 71 | fun setFileProvider(@NonNull authorities: String) { 72 | this.authorities = authorities 73 | } 74 | 75 | /** 76 | * 是否单选 77 | */ 78 | @JvmStatic 79 | fun isSinglePick() = maxNum == 1 80 | 81 | /** 82 | * 设置是否需要需要显示拍照选项,默认true 83 | */ 84 | @JvmStatic 85 | fun isNeedCamera(flag: Boolean = true): QFHelper { 86 | this.isNeedCamera = flag 87 | return this 88 | } 89 | 90 | /** 91 | * 每行显示多少张图片,默认&建议:3 92 | * 可根据手机分辨率实际情况大小进行调整 93 | */ 94 | @JvmStatic 95 | fun setSpanCount(num: Int = 3): QFHelper { 96 | this.spanCount = num 97 | return this 98 | } 99 | 100 | /** 101 | * 设置分页加载每次加载多少张图片,默认&建议:30 102 | * 可根据手机分辨率实际情况大小进行调整 103 | * 注意:该值最少应该保证首次加载充满全屏,否则无法加载更多 104 | */ 105 | @JvmStatic 106 | fun setLoadNum(num: Int = 30): QFHelper { 107 | this.loadNum = num 108 | return this 109 | } 110 | 111 | /** 112 | * 设置可选择最多maxNum张图片 113 | */ 114 | @JvmStatic 115 | fun setMaxNum(num: Int = 1): QFHelper { 116 | this.maxNum = num 117 | return this 118 | } 119 | 120 | /** 121 | * 设置是否需要Gif 122 | */ 123 | @JvmStatic 124 | fun isNeedGif(flag: Boolean = false): QFHelper { 125 | this.isNeedGif = flag 126 | return this 127 | } 128 | 129 | /** 130 | * AppCompatActivity中启动图片选择 131 | */ 132 | @JvmStatic 133 | fun start(activity: FragmentActivity, requestCode: Int) { 134 | if (assertCheck(activity)) return 135 | val intent = Intent(activity, QFImgsActivity::class.java) 136 | activity.startActivityForResult(intent, requestCode) 137 | } 138 | 139 | /** 140 | * Fragment中启动图片选择 141 | */ 142 | @JvmStatic 143 | fun start(fragment: Fragment, requestCode: Int) { 144 | if (assertCheck(fragment.requireContext())) return 145 | val intent = Intent(context, QFImgsActivity::class.java) 146 | fragment.startActivityForResult(intent, requestCode) 147 | } 148 | 149 | /** 150 | * 协程打开简易图片库,返回文件uri集合 151 | */ 152 | suspend fun startScopeUri(activity: FragmentActivity): ArrayList? { 153 | if (assertCheck(activity)) return null 154 | return suspendCancellableCoroutine { continuation -> 155 | activity.qfActivityForResult(Intent(context, QFImgsActivity::class.java)) { 156 | continuation.resumeWith(Result.success(getResult(it))) 157 | } 158 | } 159 | } 160 | 161 | suspend fun startScopeUri(fragment: Fragment): ArrayList? = startScopeUri(fragment.requireActivity()) 162 | 163 | /** 164 | * 协程打开简易图片库,返回文件路径集合 165 | */ 166 | suspend fun startScopePath(activity: FragmentActivity): ArrayList? { 167 | if (assertCheck(activity)) return null 168 | return suspendCancellableCoroutine { continuation -> 169 | activity.qfActivityForResult(Intent(context, QFImgsActivity::class.java)) { intent -> 170 | val paths = arrayListOf() 171 | getResult(intent)?.forEach { 172 | it.uriToFile()?.run { 173 | paths.add(absolutePath) 174 | } 175 | } 176 | continuation.resumeWith(Result.success(paths)) 177 | } 178 | } 179 | } 180 | 181 | suspend fun startScopePath(fragment: Fragment): ArrayList? = startScopePath(fragment.requireActivity()) 182 | 183 | 184 | /** 185 | * 打开图片浏览器 186 | * @param uri 图片URI 187 | */ 188 | @JvmStatic 189 | @JvmOverloads 190 | fun startImgBrowse(activity: FragmentActivity, uri: Uri, requestCode: Int = EXTRA_RC_IB) { 191 | val intent = Intent(activity, QFBigImgActivity::class.java) 192 | intent.putExtra(EXTRA_IMG, uri) 193 | activity.startActivityForResult(intent, requestCode) 194 | } 195 | 196 | 197 | @JvmStatic 198 | @JvmOverloads 199 | fun startImgBrowse(fragment: Fragment, uri: Uri, requestCode: Int = EXTRA_RC_IB) { 200 | val intent = Intent(fragment.requireContext(), QFBigImgActivity::class.java) 201 | intent.putExtra(EXTRA_IMG, uri) 202 | fragment.startActivityForResult(intent, requestCode) 203 | } 204 | 205 | /** 206 | * 打开图片浏览器 207 | * @param url 图片链接或者本地路径 208 | */ 209 | @JvmStatic 210 | @JvmOverloads 211 | fun startImgBrowse(activity: FragmentActivity, url: String, requestCode: Int = EXTRA_RC_IB) { 212 | val intent = Intent(activity, QFBigImgActivity::class.java) 213 | intent.putExtra(EXTRA_IMG, url) 214 | activity.startActivityForResult(intent, requestCode) 215 | } 216 | 217 | @JvmStatic 218 | @JvmOverloads 219 | fun startImgBrowse(fragment: Fragment, url: String, requestCode: Int = EXTRA_RC_IB) { 220 | startImgBrowse(fragment.requireActivity(), url, requestCode) 221 | } 222 | 223 | /** 224 | * 获取选取图片后的结果 225 | */ 226 | @JvmStatic 227 | fun getResult(data: Intent?): ArrayList? { 228 | return data?.getParcelableArrayListExtra(EXTRA_RESULT) 229 | } 230 | 231 | private fun assertCheck(context: Context): Boolean { 232 | if (maxNum < 1) { 233 | Toast.makeText(context, context.getString(R.string.qf_less_one), Toast.LENGTH_LONG).show() 234 | return true 235 | } 236 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 237 | if (context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED 238 | || context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED 239 | ) { 240 | Toast.makeText(context, context.getString(R.string.qf_storage_permission), Toast.LENGTH_LONG).show() 241 | return true 242 | } 243 | } 244 | return false 245 | } 246 | 247 | fun assertNotInit() { 248 | if (!::context.isInitialized) { 249 | throw IllegalArgumentException( 250 | "You should init context first!" 251 | ) 252 | } 253 | } 254 | 255 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/activity/QFBigImgActivity.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.activity 2 | 3 | import android.net.Uri 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import com.demon.qfsolution.QFHelper 7 | import com.demon.qfsolution.R 8 | import com.demon.qfsolution.loader.QFImgLoader 9 | import com.demon.qfsolution.photoview.PhotoView 10 | 11 | /** 12 | * @author DeMon 13 | * Created on 2020/11/5. 14 | * E-mail idemon_liu@qq.com 15 | * Desc: 大图预览 16 | */ 17 | class QFBigImgActivity : AppCompatActivity() { 18 | 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | setContentView(R.layout.activity_qf_big_img) 23 | val qf_photo_view = findViewById(R.id.qf_photo_view) 24 | val uri = intent.getParcelableExtra(QFHelper.EXTRA_IMG) 25 | if (uri == null) { 26 | val url = intent.getStringExtra(QFHelper.EXTRA_IMG) 27 | url?.let { QFImgLoader.getInstance().displayImgString(qf_photo_view, it) } 28 | } else { 29 | QFImgLoader.getInstance().displayImgUri(qf_photo_view, uri) 30 | } 31 | 32 | } 33 | 34 | 35 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/activity/QFImgsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.activity 2 | 3 | import android.Manifest 4 | import android.annotation.SuppressLint 5 | import android.content.ContentUris 6 | import android.content.Intent 7 | import android.content.pm.PackageManager 8 | import android.database.Cursor 9 | import android.net.Uri 10 | import android.os.Build 11 | import android.os.Bundle 12 | import android.provider.MediaStore 13 | import android.text.TextUtils 14 | import android.util.Log 15 | import android.widget.Button 16 | import android.widget.Toast 17 | import androidx.appcompat.app.AppCompatActivity 18 | import androidx.lifecycle.lifecycleScope 19 | import androidx.recyclerview.widget.RecyclerView 20 | import com.demon.qfsolution.* 21 | import com.demon.qfsolution.list.QFImgAdapter 22 | import com.demon.qfsolution.bean.QFImgBean 23 | import com.demon.qfsolution.list.HackyGridLayoutManager 24 | import com.demon.qfsolution.list.SpacesItemDecoration 25 | import com.demon.qfsolution.utils.getExtensionByUri 26 | import com.demon.qfsolution.utils.gotoCamera 27 | import kotlinx.coroutines.Dispatchers 28 | import kotlinx.coroutines.launch 29 | import java.io.File 30 | 31 | /** 32 | * @author DeMon 33 | * Created on 2020/11/5. 34 | * E-mail idemon_liu@qq.com 35 | * Desc: 图片选择器 36 | */ 37 | class QFImgsActivity : AppCompatActivity() { 38 | private val TAG = "QFImgsActivity" 39 | private val imgList = arrayListOf() 40 | private var cursor: Cursor? = null 41 | private var hasImgs = true 42 | private lateinit var adapter: QFImgAdapter 43 | private var index = 0 44 | 45 | private lateinit var btn_qf_ok: Button 46 | private lateinit var rv_imgs: RecyclerView 47 | override fun onCreate(savedInstanceState: Bundle?) { 48 | super.onCreate(savedInstanceState) 49 | setContentView(R.layout.activity_qf_imgs) 50 | btn_qf_ok = findViewById(R.id.btn_qf_ok) 51 | rv_imgs = findViewById(R.id.rv_imgs) 52 | if (!QFHelper.isSinglePick()) { 53 | btn_qf_ok.text = getString(R.string.qf_ok_value, 0, QFHelper.maxNum) 54 | } 55 | 56 | if (QFHelper.isNeedCamera) { 57 | imgList.add(QFImgBean(1)) 58 | } 59 | 60 | cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, "${MediaStore.MediaColumns.DATE_ADDED} desc") 61 | getImgDatas() 62 | adapter = QFImgAdapter(imgList, object : QFImgAdapter.ImgPickedListener { 63 | override fun onImgPickedChange(uris: ArrayList, size: Int) { 64 | if (!QFHelper.isSinglePick()) { 65 | btn_qf_ok.text = getString(R.string.qf_ok_value, size, QFHelper.maxNum) 66 | } 67 | } 68 | 69 | override fun onCameraClick() { 70 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 71 | if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { 72 | Toast.makeText(this@QFImgsActivity, getString(R.string.qf_camera_permission), Toast.LENGTH_LONG).show() 73 | return 74 | } 75 | } 76 | lifecycleScope.launch(Dispatchers.Main) { 77 | gotoCamera()?.run { 78 | if (!isPickedOver()) { 79 | adapter.resultList.add(this) 80 | if (!QFHelper.isSinglePick()) { 81 | btn_qf_ok.text = getString(R.string.qf_ok_value, adapter.resultList.size, QFHelper.maxNum) 82 | } 83 | } 84 | imgList.add(1, QFImgBean(this, !isPickedOver())) 85 | adapter.notifyItemInserted(1) 86 | } 87 | } 88 | } 89 | 90 | override fun onImgClick(uri: Uri) { 91 | QFHelper.startImgBrowse(this@QFImgsActivity, uri) 92 | } 93 | }) 94 | rv_imgs.setHasFixedSize(true) 95 | val gridLayoutManager = HackyGridLayoutManager(this, QFHelper.spanCount) 96 | gridLayoutManager.isSmoothScrollbarEnabled = true 97 | rv_imgs.layoutManager = gridLayoutManager 98 | rv_imgs.addItemDecoration(SpacesItemDecoration(resources.getDimensionPixelOffset(R.dimen.qf_grid_margin), QFHelper.spanCount)) 99 | rv_imgs.addOnScrollListener(ScrollListener()) 100 | rv_imgs.adapter = adapter 101 | 102 | btn_qf_ok.setOnClickListener { 103 | if (adapter.resultList.isNullOrEmpty()) { 104 | Toast.makeText(this, getString(R.string.qf_last_one), Toast.LENGTH_SHORT).show() 105 | return@setOnClickListener 106 | } 107 | 108 | val intent = Intent() 109 | intent.putExtra(QFHelper.EXTRA_RESULT, adapter.resultList) 110 | setResult(RESULT_OK, intent) 111 | finish() 112 | } 113 | } 114 | 115 | @SuppressLint("Range") 116 | private fun getImgDatas() { 117 | index = 0 118 | cursor?.run { 119 | while (moveToNext() && index <= QFHelper.loadNum) { 120 | val picPath = getString(getColumnIndex(MediaStore.Images.Media.DATA)) ?: "" 121 | if (TextUtils.isEmpty(picPath) || !File(picPath).exists()) { 122 | Log.i(TAG, "getImgDatas: $picPath no exists") 123 | } else { 124 | val id = getLong(getColumnIndexOrThrow(MediaStore.Images.Media._ID)) 125 | val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) 126 | if (!QFHelper.isNeedGif && uri.getExtensionByUri() == "gif") { 127 | continue 128 | } 129 | imgList.add(QFImgBean(uri, picPath)) 130 | index++ 131 | } 132 | } 133 | } 134 | if (index < QFHelper.loadNum) { 135 | hasImgs = false 136 | cursor?.close() 137 | } else { 138 | hasImgs = true 139 | } 140 | 141 | } 142 | 143 | fun isPickedOver() = adapter.resultList.size == QFHelper.maxNum 144 | 145 | /** 146 | * 加载更多 147 | */ 148 | private inner class ScrollListener : RecyclerView.OnScrollListener() { 149 | override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 150 | val childCount = recyclerView.childCount 151 | if (childCount > 0) { 152 | val lastChild = recyclerView.getChildAt(childCount - 1) 153 | val itemCount = recyclerView.adapter?.itemCount ?: 0 154 | val lastVisible = recyclerView.getChildAdapterPosition(lastChild) 155 | if (lastVisible == itemCount - 1 && hasImgs) { 156 | getImgDatas() 157 | adapter.notifyItemRangeInserted(itemCount, index) 158 | } 159 | } 160 | } 161 | } 162 | 163 | 164 | override fun onDestroy() { 165 | super.onDestroy() 166 | cursor?.close() 167 | } 168 | 169 | 170 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/bean/QFImgBean.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.bean 2 | 3 | import android.net.Uri 4 | 5 | /** 6 | * @author DeMon 7 | * Created on 2020/11/3. 8 | * E-mail idemon_liu@qq.com 9 | * Desc: 10 | */ 11 | class QFImgBean { 12 | lateinit var uri: Uri 13 | var path: String = "" 14 | var type = 0 //0正常图片,1拍照选项 15 | var isSelected = false 16 | 17 | constructor(uri: Uri, path: String) { 18 | this.uri = uri 19 | this.path = path; 20 | } 21 | 22 | constructor(type: Int) { 23 | this.type = type 24 | } 25 | 26 | constructor(uri: Uri, isSelected: Boolean) { 27 | this.uri = uri 28 | this.isSelected = isSelected 29 | } 30 | 31 | 32 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/fragment/QFGhostFragment.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.fragment 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import androidx.fragment.app.Fragment 7 | import androidx.fragment.app.FragmentActivity 8 | import kotlin.random.Random 9 | 10 | class QFGhostFragment : Fragment() { 11 | 12 | private var requestCode = 0x1024 13 | private var intent: Intent? = null 14 | private var callback: ((result: Intent?) -> Unit)? = null 15 | 16 | fun init(intent: Intent, callback: ((result: Intent?) -> Unit)) { 17 | this.requestCode = Random.nextInt(1, 1000) 18 | this.intent = intent 19 | this.callback = callback 20 | } 21 | 22 | //https://github.com/wuyr/ActivityMessenger/issues/6 23 | private var activityStarted = false 24 | 25 | override fun onAttach(activity: Activity) { 26 | super.onAttach(activity) 27 | if (!activityStarted) { 28 | activityStarted = true 29 | intent?.let { startActivityForResult(it, requestCode) } 30 | } 31 | } 32 | 33 | override fun onAttach(context: Context) { 34 | super.onAttach(context) 35 | if (!activityStarted) { 36 | activityStarted = true 37 | intent?.let { startActivityForResult(it, requestCode) } 38 | } 39 | } 40 | 41 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 42 | super.onActivityResult(requestCode, resultCode, data) 43 | if (resultCode == Activity.RESULT_OK && requestCode == this.requestCode) { 44 | callback?.let { it(data) } 45 | } 46 | } 47 | 48 | override fun onDetach() { 49 | super.onDetach() 50 | intent = null 51 | callback = null 52 | } 53 | } 54 | 55 | 56 | inline fun Fragment.qfActivityForResult( 57 | intent: Intent, 58 | requestCode: Int = Random.nextInt(1, 1000), 59 | crossinline callback: ((result: Intent?) -> Unit) 60 | ) { 61 | val fragment = QFGhostFragment() 62 | fragment.init(intent) { result -> 63 | callback(result) 64 | childFragmentManager.beginTransaction().remove(fragment).commitAllowingStateLoss() 65 | } 66 | childFragmentManager.beginTransaction().add(fragment, QFGhostFragment::class.java.simpleName) 67 | .commitAllowingStateLoss() 68 | } 69 | 70 | inline fun FragmentActivity.qfActivityForResult( 71 | intent: Intent, 72 | crossinline callback: ((result: Intent?) -> Unit) 73 | ) { 74 | val fragment = QFGhostFragment() 75 | fragment.init(intent) { result -> 76 | callback(result) 77 | supportFragmentManager.beginTransaction().remove(fragment).commitAllowingStateLoss() 78 | } 79 | supportFragmentManager.beginTransaction().add(fragment, QFGhostFragment::class.java.simpleName) 80 | .commitAllowingStateLoss() 81 | } 82 | -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/list/HackyGridLayoutManager.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.list 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import androidx.recyclerview.widget.GridLayoutManager 6 | import androidx.recyclerview.widget.RecyclerView 7 | 8 | /** 9 | * Created by ChenSL on 2018/3/22. 10 | */ 11 | class HackyGridLayoutManager : GridLayoutManager { 12 | 13 | 14 | constructor( 15 | context: Context?, 16 | attrs: AttributeSet?, 17 | defStyleAttr: Int, 18 | defStyleRes: Int 19 | ) : super(context, attrs, defStyleAttr, defStyleRes) 20 | 21 | 22 | constructor(context: Context?, spanCount: Int) : super(context, spanCount) {} 23 | 24 | constructor( 25 | context: Context?, 26 | spanCount: Int, 27 | orientation: Int, 28 | reverseLayout: Boolean 29 | ) : super(context, spanCount, orientation, reverseLayout) { 30 | } 31 | 32 | override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { 33 | try { 34 | super.onLayoutChildren(recycler, state) 35 | } catch (e: IndexOutOfBoundsException) { 36 | e.printStackTrace() 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/list/QFImgAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.list 2 | 3 | import android.net.Uri 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.ImageView 8 | import android.widget.Toast 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.demon.qfsolution.QFHelper 11 | import com.demon.qfsolution.R 12 | import com.demon.qfsolution.bean.QFImgBean 13 | import com.demon.qfsolution.loader.QFImgLoader 14 | 15 | 16 | /** 17 | * @author DeMon 18 | * Created on 2020/11/3. 19 | * E-mail idemon_liu@qq.com 20 | * Desc: 21 | */ 22 | class QFImgAdapter constructor(private var imgList: MutableList, private var listener: ImgPickedListener? = null) : RecyclerView.Adapter() { 23 | 24 | val resultList = arrayListOf() 25 | 26 | class ViewHolder constructor(itemView: View) : RecyclerView.ViewHolder(itemView) { 27 | val mContext = itemView.context 28 | val qf_pick = itemView.findViewById(R.id.qf_pick) 29 | val qf_img = itemView.findViewById(R.id.qf_img) 30 | } 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_qf_img, parent, false)) 34 | } 35 | 36 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 37 | holder.run { 38 | val bean = imgList[position] 39 | if (bean.type == 1 && position == 0) { 40 | qf_pick.visibility = View.GONE 41 | qf_img.setImageResource(R.drawable.ic_qf_camera) 42 | qf_img.scaleType = ImageView.ScaleType.CENTER 43 | qf_img.setOnClickListener { 44 | listener?.onCameraClick() 45 | } 46 | } else { 47 | qf_pick.visibility = View.VISIBLE 48 | val uri = bean.uri 49 | QFImgLoader.getInstance().displayThumbnail(qf_img, uri) 50 | qf_pick.setImageResource( 51 | if (bean.isSelected) { 52 | R.drawable.ic_qf_checked 53 | } else { 54 | R.drawable.qf_unchecked 55 | } 56 | ) 57 | qf_pick.setOnClickListener { 58 | if (!bean.isSelected && resultList.size >= QFHelper.maxNum) { 59 | Toast.makeText(mContext, mContext.getString(R.string.qf_no_more), Toast.LENGTH_SHORT).show() 60 | return@setOnClickListener 61 | } 62 | if (bean.isSelected) { 63 | bean.isSelected = false 64 | resultList.remove(uri) 65 | } else { 66 | bean.isSelected = true 67 | resultList.add(uri) 68 | } 69 | listener?.onImgPickedChange(resultList, resultList.size) 70 | notifyItemChanged(position) 71 | } 72 | qf_img.setOnClickListener { 73 | listener?.onImgClick(uri) 74 | } 75 | } 76 | } 77 | 78 | } 79 | 80 | override fun getItemCount(): Int = imgList.size 81 | 82 | 83 | interface ImgPickedListener { 84 | fun onImgPickedChange(uris: ArrayList, size: Int) 85 | 86 | fun onCameraClick() 87 | 88 | fun onImgClick(uri: Uri) 89 | } 90 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/list/SpacesItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.list 2 | 3 | import android.graphics.Rect 4 | import android.view.View 5 | import androidx.recyclerview.widget.GridLayoutManager 6 | import androidx.recyclerview.widget.RecyclerView 7 | import androidx.recyclerview.widget.RecyclerView.ItemDecoration 8 | import androidx.recyclerview.widget.StaggeredGridLayoutManager 9 | 10 | /** 11 | * @author ChenSL 12 | */ 13 | class SpacesItemDecoration @JvmOverloads constructor( 14 | private val mSpace: Int, 15 | private val mSpanCount: Int = 1 16 | ) : ItemDecoration() { 17 | private val mRadixX: Int = mSpace / mSpanCount 18 | private var mItemCountInLastLine = 0 19 | private var mOldItemCount = -1 20 | 21 | override fun getItemOffsets( 22 | outRect: Rect, 23 | view: View, 24 | parent: RecyclerView, 25 | state: RecyclerView.State 26 | ) { 27 | val params = view.layoutParams as RecyclerView.LayoutParams 28 | val sumCount = state.itemCount 29 | val position = params.viewLayoutPosition 30 | val spanSize: Int 31 | val index: Int 32 | when (params) { 33 | is GridLayoutManager.LayoutParams -> { 34 | spanSize = params.spanSize 35 | index = params.spanIndex 36 | if ((position == 0 || mOldItemCount != sumCount) && mSpanCount > 1) { 37 | var countInLine = 0 38 | var spanIndex: Int 39 | for (tempPosition in sumCount - mSpanCount until sumCount) { 40 | spanIndex = (parent.layoutManager as GridLayoutManager).spanSizeLookup?.getSpanIndex(tempPosition, mSpanCount) ?: 0 41 | countInLine = if (spanIndex == 0) 1 else countInLine + 1 42 | } 43 | mItemCountInLastLine = countInLine 44 | if (mOldItemCount != sumCount) { 45 | mOldItemCount = sumCount 46 | if (position != 0) { 47 | parent.post { parent.invalidateItemDecorations() } 48 | } 49 | } 50 | } 51 | } 52 | is StaggeredGridLayoutManager.LayoutParams -> { 53 | spanSize = if (params.isFullSpan) mSpanCount else 1 54 | index = params.spanIndex 55 | } 56 | else -> { 57 | spanSize = 1 58 | index = 0 59 | } 60 | } 61 | if (spanSize < 1 || index < 0 || spanSize > mSpanCount) { 62 | return 63 | } 64 | outRect.left = mSpace - mRadixX * index 65 | outRect.right = mRadixX + mRadixX * (index + spanSize - 1) 66 | when { 67 | mSpanCount == 1 && position == sumCount - 1 -> { 68 | outRect.bottom = mSpace 69 | } 70 | position >= sumCount - mItemCountInLastLine && position < sumCount -> { 71 | outRect.bottom = mSpace 72 | } 73 | } 74 | outRect.top = mSpace 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/loader/IQFImgLoader.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.loader 2 | 3 | import android.net.Uri 4 | import android.widget.ImageView 5 | 6 | /** 7 | * @author DeMon 8 | * Created on 2020/11/5. 9 | * E-mail idemon_liu@qq.com 10 | * Desc: 图片加载器接口,定义如何加载图片 11 | */ 12 | interface IQFImgLoader { 13 | 14 | /** 15 | * 图片显示在表格选择器中的缩略图 16 | * @param img ImageView 17 | * @param uri 18 | */ 19 | fun displayThumbnail(img: ImageView, uri: Uri) 20 | 21 | 22 | /** 23 | * 一个Uri图片,使用大图预览时 24 | * @param img ImageView 25 | * @param uri 图片Uri 26 | */ 27 | fun displayImgUri(img: ImageView, uri: Uri) 28 | 29 | 30 | /** 31 | * 一个图片Url或者图片路径,使用大图预览时 32 | * @param img ImageView 33 | * @param str 图片Url或者图片路径 34 | */ 35 | fun displayImgString(img: ImageView, str: String) 36 | 37 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/loader/QFImgLoader.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.loader 2 | 3 | import android.net.Uri 4 | import android.widget.ImageView 5 | import androidx.annotation.NonNull 6 | 7 | /** 8 | * @author DeMon 9 | * Created on 2020/11/5. 10 | * E-mail idemon_liu@qq.com 11 | * Desc: 12 | */ 13 | class QFImgLoader : IQFImgLoader { 14 | private var loader: IQFImgLoader? = null 15 | 16 | fun init(@NonNull loader: IQFImgLoader) { 17 | this.loader = loader 18 | } 19 | 20 | override fun displayThumbnail(img: ImageView, uri: Uri) { 21 | loader?.displayThumbnail(img, uri) 22 | } 23 | 24 | override fun displayImgString(img: ImageView, str: String) { 25 | loader?.displayImgString(img, str) 26 | } 27 | 28 | override fun displayImgUri(img: ImageView, uri: Uri) { 29 | loader?.displayImgUri(img, uri) 30 | } 31 | 32 | companion object { 33 | 34 | @Volatile 35 | private var instance: QFImgLoader? = null 36 | 37 | @JvmStatic 38 | fun getInstance(): QFImgLoader { 39 | return instance ?: synchronized(this) { 40 | instance ?: QFImgLoader().also { instance = it } 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/Compat.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.annotation.TargetApi 4 | import android.os.Build.VERSION 5 | import android.os.Build.VERSION_CODES 6 | import android.view.View 7 | 8 | internal object Compat { 9 | private const val SIXTY_FPS_INTERVAL = 1000 / 60 10 | fun postOnAnimation(view: View, runnable: Runnable) { 11 | postOnAnimationJellyBean(view, runnable) 12 | } 13 | 14 | @TargetApi(16) 15 | private fun postOnAnimationJellyBean(view: View, runnable: Runnable) { 16 | view.postOnAnimation(runnable) 17 | } 18 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/CustomGestureDetector.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.content.Context 4 | import android.view.MotionEvent 5 | import android.view.ScaleGestureDetector 6 | import android.view.ScaleGestureDetector.OnScaleGestureListener 7 | import android.view.VelocityTracker 8 | import android.view.ViewConfiguration 9 | 10 | /** 11 | * Does a whole lot of gesture detecting. 12 | */ 13 | internal class CustomGestureDetector(context: Context?, listener: OnGestureListener) { 14 | private var mActivePointerId = INVALID_POINTER_ID 15 | private var mActivePointerIndex = 0 16 | private val mDetector: ScaleGestureDetector 17 | private var mVelocityTracker: VelocityTracker? = null 18 | var isDragging = false 19 | private set 20 | private var mLastTouchX = 0f 21 | private var mLastTouchY = 0f 22 | private val mTouchSlop: Float 23 | private val mMinimumVelocity: Float 24 | private val mListener: OnGestureListener 25 | private fun getActiveX(ev: MotionEvent): Float { 26 | return try { 27 | ev.getX(mActivePointerIndex) 28 | } catch (e: Exception) { 29 | ev.x 30 | } 31 | } 32 | 33 | private fun getActiveY(ev: MotionEvent): Float { 34 | return try { 35 | ev.getY(mActivePointerIndex) 36 | } catch (e: Exception) { 37 | ev.y 38 | } 39 | } 40 | 41 | val isScaling: Boolean 42 | get() = mDetector.isInProgress 43 | 44 | fun onTouchEvent(ev: MotionEvent): Boolean { 45 | return try { 46 | mDetector.onTouchEvent(ev) 47 | processTouchEvent(ev) 48 | } catch (e: IllegalArgumentException) { 49 | // Fix for support lib bug, happening when onDestroy is called 50 | true 51 | } 52 | } 53 | 54 | private fun processTouchEvent(ev: MotionEvent): Boolean { 55 | val action = ev.action 56 | when (action and MotionEvent.ACTION_MASK) { 57 | MotionEvent.ACTION_DOWN -> { 58 | mActivePointerId = ev.getPointerId(0) 59 | mVelocityTracker = VelocityTracker.obtain() 60 | if (null != mVelocityTracker) { 61 | mVelocityTracker!!.addMovement(ev) 62 | } 63 | mLastTouchX = getActiveX(ev) 64 | mLastTouchY = getActiveY(ev) 65 | isDragging = false 66 | } 67 | MotionEvent.ACTION_MOVE -> { 68 | val x = getActiveX(ev) 69 | val y = getActiveY(ev) 70 | val dx = x - mLastTouchX 71 | val dy = y - mLastTouchY 72 | if (!isDragging) { 73 | // Use Pythagoras to see if drag length is larger than 74 | // touch slop 75 | isDragging = Math.sqrt(dx * dx + (dy * dy).toDouble()) >= mTouchSlop 76 | } 77 | if (isDragging) { 78 | mListener.onDrag(dx, dy) 79 | mLastTouchX = x 80 | mLastTouchY = y 81 | if (null != mVelocityTracker) { 82 | mVelocityTracker!!.addMovement(ev) 83 | } 84 | } 85 | } 86 | MotionEvent.ACTION_CANCEL -> { 87 | mActivePointerId = INVALID_POINTER_ID 88 | // Recycle Velocity Tracker 89 | if (null != mVelocityTracker) { 90 | mVelocityTracker!!.recycle() 91 | mVelocityTracker = null 92 | } 93 | } 94 | MotionEvent.ACTION_UP -> { 95 | mActivePointerId = INVALID_POINTER_ID 96 | if (isDragging) { 97 | if (null != mVelocityTracker) { 98 | mLastTouchX = getActiveX(ev) 99 | mLastTouchY = getActiveY(ev) 100 | 101 | // Compute velocity within the last 1000ms 102 | mVelocityTracker!!.addMovement(ev) 103 | mVelocityTracker!!.computeCurrentVelocity(1000) 104 | val vX = mVelocityTracker!!.xVelocity 105 | val vY = mVelocityTracker!! 106 | .yVelocity 107 | 108 | // If the velocity is greater than minVelocity, call 109 | // listener 110 | if (Math.max(Math.abs(vX), Math.abs(vY)) >= mMinimumVelocity) { 111 | mListener.onFling( 112 | mLastTouchX, mLastTouchY, -vX, 113 | -vY 114 | ) 115 | } 116 | } 117 | } 118 | 119 | // Recycle Velocity Tracker 120 | if (null != mVelocityTracker) { 121 | mVelocityTracker!!.recycle() 122 | mVelocityTracker = null 123 | } 124 | } 125 | MotionEvent.ACTION_POINTER_UP -> { 126 | val pointerIndex = Util.getPointerIndex(ev.action) 127 | val pointerId = ev.getPointerId(pointerIndex) 128 | if (pointerId == mActivePointerId) { 129 | // This was our active pointer going up. Choose a new 130 | // active pointer and adjust accordingly. 131 | val newPointerIndex = if (pointerIndex == 0) 1 else 0 132 | mActivePointerId = ev.getPointerId(newPointerIndex) 133 | mLastTouchX = ev.getX(newPointerIndex) 134 | mLastTouchY = ev.getY(newPointerIndex) 135 | } 136 | } 137 | } 138 | mActivePointerIndex = ev 139 | .findPointerIndex(if (mActivePointerId != INVALID_POINTER_ID) mActivePointerId else 0) 140 | return true 141 | } 142 | 143 | companion object { 144 | private const val INVALID_POINTER_ID = -1 145 | } 146 | 147 | init { 148 | val configuration = ViewConfiguration 149 | .get(context) 150 | mMinimumVelocity = configuration.scaledMinimumFlingVelocity.toFloat() 151 | mTouchSlop = configuration.scaledTouchSlop.toFloat() 152 | mListener = listener 153 | val mScaleListener: OnScaleGestureListener = object : OnScaleGestureListener { 154 | override fun onScale(detector: ScaleGestureDetector): Boolean { 155 | val scaleFactor = detector.scaleFactor 156 | if (java.lang.Float.isNaN(scaleFactor) || java.lang.Float.isInfinite(scaleFactor)) return false 157 | if (scaleFactor >= 0) { 158 | mListener.onScale( 159 | scaleFactor, 160 | detector.focusX, detector.focusY 161 | ) 162 | } 163 | return true 164 | } 165 | 166 | override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { 167 | return true 168 | } 169 | 170 | override fun onScaleEnd(detector: ScaleGestureDetector) { 171 | // NO-OP 172 | } 173 | } 174 | mDetector = ScaleGestureDetector(context, mScaleListener) 175 | } 176 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnGestureListener.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.demon.qfsolution.photoview 3 | 4 | 5 | internal interface OnGestureListener { 6 | fun onDrag(dx: Float, dy: Float) 7 | fun onFling( 8 | startX: Float, startY: Float, velocityX: Float, 9 | velocityY: Float 10 | ) 11 | 12 | fun onScale(scaleFactor: Float, focusX: Float, focusY: Float) 13 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnMatrixChangedListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.graphics.RectF 4 | 5 | /** 6 | * Interface definition for a callback to be invoked when the internal Matrix has changed for 7 | * this View. 8 | */ 9 | interface OnMatrixChangedListener { 10 | /** 11 | * Callback for when the Matrix displaying the Drawable has changed. This could be because 12 | * the View's bounds have changed, or the user has zoomed. 13 | * 14 | * @param rect - Rectangle displaying the Drawable's new bounds. 15 | */ 16 | fun onMatrixChanged(rect: RectF?) 17 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnOutsidePhotoTapListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.widget.ImageView 4 | 5 | /** 6 | * Callback when the user tapped outside of the photo 7 | */ 8 | interface OnOutsidePhotoTapListener { 9 | /** 10 | * The outside of the photo has been tapped 11 | */ 12 | fun onOutsidePhotoTap(imageView: ImageView?) 13 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnPhotoTapListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.widget.ImageView 4 | /** 5 | * A callback to be invoked when the Photo is tapped with a single 6 | * tap. 7 | */ 8 | interface OnPhotoTapListener { 9 | /** 10 | * A callback to receive where the user taps on a photo. You will only receive a callback if 11 | * the user taps on the actual photo, tapping on 'whitespace' will be ignored. 12 | * 13 | * @param view ImageView the user tapped. 14 | * @param x where the user tapped from the of the Drawable, as percentage of the 15 | * Drawable width. 16 | * @param y where the user tapped from the top of the Drawable, as percentage of the 17 | * Drawable height. 18 | */ 19 | fun onPhotoTap(view: ImageView?, x: Float, y: Float) 20 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnScaleChangedListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | 4 | /** 5 | * Interface definition for callback to be invoked when attached ImageView scale changes 6 | */ 7 | interface OnScaleChangedListener { 8 | /** 9 | * Callback for when the scale changes 10 | * 11 | * @param scaleFactor the scale factor (less than 1 for zoom out, greater than 1 for zoom in) 12 | * @param focusX focal point X position 13 | * @param focusY focal point Y position 14 | */ 15 | fun onScaleChange(scaleFactor: Float, focusX: Float, focusY: Float) 16 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnSingleFlingListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.view.MotionEvent 4 | 5 | /** 6 | * A callback to be invoked when the ImageView is flung with a single 7 | * touch 8 | */ 9 | interface OnSingleFlingListener { 10 | /** 11 | * A callback to receive where the user flings on a ImageView. You will receive a callback if 12 | * the user flings anywhere on the view. 13 | * 14 | * @param e1 MotionEvent the user first touch. 15 | * @param e2 MotionEvent the user last touch. 16 | * @param velocityX distance of user's horizontal fling. 17 | * @param velocityY distance of user's vertical fling. 18 | */ 19 | fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean 20 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnViewDragListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | /** 4 | * Interface definition for a callback to be invoked when the photo is experiencing a drag event 5 | */ 6 | interface OnViewDragListener { 7 | /** 8 | * Callback for when the photo is experiencing a drag event. This cannot be invoked when the 9 | * user is scaling. 10 | * 11 | * @param dx The change of the coordinates in the x-direction 12 | * @param dy The change of the coordinates in the y-direction 13 | */ 14 | fun onDrag(dx: Float, dy: Float) 15 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/OnViewTapListener.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.view.View 4 | 5 | interface OnViewTapListener { 6 | /** 7 | * A callback to receive where the user taps on a ImageView. You will receive a callback if 8 | * the user taps anywhere on the view, tapping on 'whitespace' will not be ignored. 9 | * 10 | * @param view - View the user tapped. 11 | * @param x - where the user tapped from the left of the View. 12 | * @param y - where the user tapped from the top of the View. 13 | */ 14 | fun onViewTap(view: View?, x: Float, y: Float) 15 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/PhotoView.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.demon.qfsolution.photoview 3 | 4 | import android.content.Context 5 | import android.graphics.Matrix 6 | import android.graphics.RectF 7 | import android.graphics.drawable.Drawable 8 | import android.net.Uri 9 | import android.util.AttributeSet 10 | import android.view.GestureDetector.OnDoubleTapListener 11 | import androidx.appcompat.widget.AppCompatImageView 12 | 13 | /** 14 | * A zoomable ImageView. See [PhotoViewAttacher] for most of the details on how the zooming 15 | * is accomplished 16 | */ 17 | class PhotoView @JvmOverloads constructor(context: Context?, attr: AttributeSet? = null, defStyle: Int = 0) : AppCompatImageView(context!!, attr, defStyle) { 18 | /** 19 | * Get the current [PhotoViewAttacher] for this view. Be wary of holding on to references 20 | * to this attacher, as it has a reference to this view, which, if a reference is held in the 21 | * wrong place, can cause memory leaks. 22 | * 23 | * @return the attacher. 24 | */ 25 | lateinit var attacher: PhotoViewAttacher 26 | private set 27 | private var pendingScaleType: ScaleType? = null 28 | private fun init() { 29 | attacher = PhotoViewAttacher(this) 30 | //We always pose as a Matrix scale type, though we can change to another scale type 31 | //via the attacher 32 | super.setScaleType(ScaleType.MATRIX) 33 | //apply the previously applied scale type 34 | if (pendingScaleType != null) { 35 | scaleType = pendingScaleType!! 36 | pendingScaleType = null 37 | } 38 | } 39 | 40 | override fun getScaleType(): ScaleType { 41 | return attacher.scaleType 42 | } 43 | 44 | override fun getImageMatrix(): Matrix { 45 | return attacher.imageMatrix 46 | } 47 | 48 | override fun setOnLongClickListener(l: OnLongClickListener?) { 49 | attacher.setOnLongClickListener(l) 50 | } 51 | 52 | override fun setOnClickListener(l: OnClickListener?) { 53 | attacher.setOnClickListener(l) 54 | } 55 | 56 | override fun setScaleType(scaleType: ScaleType) { 57 | attacher.scaleType = scaleType 58 | } 59 | 60 | override fun setImageDrawable(drawable: Drawable?) { 61 | super.setImageDrawable(drawable) 62 | // setImageBitmap calls through to this method 63 | attacher.update() 64 | } 65 | 66 | override fun setImageResource(resId: Int) { 67 | super.setImageResource(resId) 68 | attacher.update() 69 | } 70 | 71 | override fun setImageURI(uri: Uri?) { 72 | super.setImageURI(uri) 73 | attacher.update() 74 | } 75 | 76 | override fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean { 77 | val changed = super.setFrame(l, t, r, b) 78 | if (changed) { 79 | attacher.update() 80 | } 81 | return changed 82 | } 83 | 84 | fun setRotationTo(rotationDegree: Float) { 85 | attacher.setRotationTo(rotationDegree) 86 | } 87 | 88 | fun setRotationBy(rotationDegree: Float) { 89 | attacher.setRotationBy(rotationDegree) 90 | } 91 | 92 | var isZoomable: Boolean 93 | get() = attacher.isZoomable 94 | set(zoomable) { 95 | attacher.isZoomable = zoomable 96 | } 97 | val displayRect: RectF? 98 | get() = attacher.displayRect 99 | 100 | fun getDisplayMatrix(matrix: Matrix) { 101 | attacher.getDisplayMatrix(matrix) 102 | } 103 | 104 | fun setDisplayMatrix(finalRectangle: Matrix?): Boolean { 105 | return attacher.setDisplayMatrix(finalRectangle) 106 | } 107 | 108 | fun getSuppMatrix(matrix: Matrix) { 109 | attacher.getSuppMatrix(matrix) 110 | } 111 | 112 | fun setSuppMatrix(matrix: Matrix?): Boolean { 113 | return attacher!!.setDisplayMatrix(matrix) 114 | } 115 | 116 | var minimumScale: Float 117 | get() = attacher.minimumScale 118 | set(minimumScale) { 119 | attacher.minimumScale = minimumScale 120 | } 121 | var mediumScale: Float 122 | get() = attacher.mediumScale 123 | set(mediumScale) { 124 | attacher.mediumScale = mediumScale 125 | } 126 | var maximumScale: Float 127 | get() = attacher.maximumScale 128 | set(maximumScale) { 129 | attacher.maximumScale=maximumScale 130 | } 131 | var scale: Float 132 | get() = attacher.scale 133 | set(scale) { 134 | attacher.scale=scale 135 | } 136 | 137 | fun setAllowParentInterceptOnEdge(allow: Boolean) { 138 | attacher.setAllowParentInterceptOnEdge(allow) 139 | } 140 | 141 | fun setScaleLevels(minimumScale: Float, mediumScale: Float, maximumScale: Float) { 142 | attacher.setScaleLevels(minimumScale, mediumScale, maximumScale) 143 | } 144 | 145 | fun setOnMatrixChangeListener(listener: OnMatrixChangedListener?) { 146 | attacher.setOnMatrixChangeListener(listener) 147 | } 148 | 149 | fun setOnPhotoTapListener(listener: OnPhotoTapListener?) { 150 | attacher.setOnPhotoTapListener(listener) 151 | } 152 | 153 | fun setOnOutsidePhotoTapListener(listener: OnOutsidePhotoTapListener?) { 154 | attacher.setOnOutsidePhotoTapListener(listener) 155 | } 156 | 157 | fun setOnViewTapListener(listener: OnViewTapListener?) { 158 | attacher.setOnViewTapListener(listener) 159 | } 160 | 161 | fun setOnViewDragListener(listener: OnViewDragListener?) { 162 | attacher.setOnViewDragListener(listener) 163 | } 164 | 165 | fun setScale(scale: Float, animate: Boolean) { 166 | attacher.setScale(scale, animate) 167 | } 168 | 169 | fun setScale(scale: Float, focalX: Float, focalY: Float, animate: Boolean) { 170 | attacher.setScale(scale, focalX, focalY, animate) 171 | } 172 | 173 | fun setZoomTransitionDuration(milliseconds: Int) { 174 | attacher.setZoomTransitionDuration(milliseconds) 175 | } 176 | 177 | fun setOnDoubleTapListener(onDoubleTapListener: OnDoubleTapListener?) { 178 | attacher.setOnDoubleTapListener(onDoubleTapListener) 179 | } 180 | 181 | fun setOnScaleChangeListener(onScaleChangedListener: OnScaleChangedListener?) { 182 | attacher.setOnScaleChangeListener(onScaleChangedListener) 183 | } 184 | 185 | fun setOnSingleFlingListener(onSingleFlingListener: OnSingleFlingListener?) { 186 | attacher.setOnSingleFlingListener(onSingleFlingListener) 187 | } 188 | 189 | init { 190 | init() 191 | } 192 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/PhotoViewAttacher.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.demon.qfsolution.photoview 3 | 4 | import android.content.Context 5 | import android.graphics.Matrix 6 | import android.graphics.Matrix.ScaleToFit 7 | import android.graphics.RectF 8 | import android.graphics.drawable.Drawable 9 | import android.view.GestureDetector 10 | import android.view.GestureDetector.OnDoubleTapListener 11 | import android.view.GestureDetector.SimpleOnGestureListener 12 | import android.view.MotionEvent 13 | import android.view.View 14 | import android.view.View.OnLongClickListener 15 | import android.view.View.OnTouchListener 16 | import android.view.animation.AccelerateDecelerateInterpolator 17 | import android.view.animation.Interpolator 18 | import android.widget.ImageView 19 | import android.widget.ImageView.ScaleType 20 | import android.widget.OverScroller 21 | import com.demon.qfsolution.photoview.Util.checkZoomLevels 22 | import com.demon.qfsolution.photoview.Util.hasDrawable 23 | import com.demon.qfsolution.photoview.Util.isSupportedScaleType 24 | import kotlin.math.pow 25 | import kotlin.math.sqrt 26 | 27 | /** 28 | * The component of [PhotoView] which does the work allowing for zooming, scaling, panning, etc. 29 | * It is made public in case you need to subclass something other than AppCompatImageView and still 30 | * gain the functionality that [PhotoView] offers 31 | */ 32 | class PhotoViewAttacher(private val mImageView: ImageView) : OnTouchListener, View.OnLayoutChangeListener { 33 | private var mInterpolator: Interpolator = AccelerateDecelerateInterpolator() 34 | private var mZoomDuration = DEFAULT_ZOOM_DURATION 35 | private var mMinScale = DEFAULT_MIN_SCALE 36 | private var mMidScale = DEFAULT_MID_SCALE 37 | private var mMaxScale = DEFAULT_MAX_SCALE 38 | private var mAllowParentInterceptOnEdge = true 39 | private var mBlockParentIntercept = false 40 | 41 | // Gesture Detectors 42 | private lateinit var mGestureDetector: GestureDetector 43 | private lateinit var mScaleDragDetector: CustomGestureDetector 44 | 45 | // These are set so we don't keep allocating them on the heap 46 | private val mBaseMatrix = Matrix() 47 | val imageMatrix = Matrix() 48 | private val mSuppMatrix = Matrix() 49 | private val mDisplayRect = RectF() 50 | private val mMatrixValues = FloatArray(9) 51 | 52 | // Listeners 53 | private var mMatrixChangeListener: OnMatrixChangedListener? = null 54 | private var mPhotoTapListener: OnPhotoTapListener? = null 55 | private var mOutsidePhotoTapListener: OnOutsidePhotoTapListener? = null 56 | private var mViewTapListener: OnViewTapListener? = null 57 | private var mOnClickListener: View.OnClickListener? = null 58 | private var mLongClickListener: OnLongClickListener? = null 59 | private var mScaleChangeListener: OnScaleChangedListener? = null 60 | private var mSingleFlingListener: OnSingleFlingListener? = null 61 | private var mOnViewDragListener: OnViewDragListener? = null 62 | private var mCurrentFlingRunnable: FlingRunnable? = null 63 | private var mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH 64 | private var mVerticalScrollEdge = VERTICAL_EDGE_BOTH 65 | private var mBaseRotation: Float = 0.0f 66 | 67 | @get:Deprecated("") 68 | var isZoomEnabled = true 69 | private set 70 | private var mScaleType = ScaleType.FIT_CENTER 71 | private val onGestureListener: OnGestureListener = object : OnGestureListener { 72 | override fun onDrag(dx: Float, dy: Float) { 73 | if (mScaleDragDetector.isScaling) { 74 | return // Do not drag if we are already scaling 75 | } 76 | if (mOnViewDragListener != null) { 77 | mOnViewDragListener!!.onDrag(dx, dy) 78 | } 79 | mSuppMatrix.postTranslate(dx, dy) 80 | checkAndDisplayMatrix() 81 | 82 | /* 83 | * Here we decide whether to let the ImageView's parent to start taking 84 | * over the touch event. 85 | * 86 | * First we check whether this function is enabled. We never want the 87 | * parent to take over if we're scaling. We then check the edge we're 88 | * on, and the direction of the scroll (i.e. if we're pulling against 89 | * the edge, aka 'overscrolling', let the parent take over). 90 | */ 91 | val parent = mImageView.parent 92 | if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling&& !mBlockParentIntercept) { 93 | if (mHorizontalScrollEdge == HORIZONTAL_EDGE_BOTH || mHorizontalScrollEdge == HORIZONTAL_EDGE_LEFT && dx >= 1f 94 | || mHorizontalScrollEdge == HORIZONTAL_EDGE_RIGHT && dx <= -1f 95 | || mVerticalScrollEdge == VERTICAL_EDGE_TOP && dy >= 1f 96 | || mVerticalScrollEdge == VERTICAL_EDGE_BOTTOM && dy <= -1f 97 | ) { 98 | parent?.requestDisallowInterceptTouchEvent(false) 99 | } 100 | } else { 101 | parent?.requestDisallowInterceptTouchEvent(true) 102 | } 103 | } 104 | 105 | override fun onFling(startX: Float, startY: Float, velocityX: Float, velocityY: Float) { 106 | mCurrentFlingRunnable = FlingRunnable(mImageView.context) 107 | mCurrentFlingRunnable!!.fling( 108 | getImageViewWidth(mImageView), 109 | getImageViewHeight(mImageView), velocityX.toInt(), velocityY.toInt() 110 | ) 111 | mImageView.post(mCurrentFlingRunnable) 112 | } 113 | 114 | override fun onScale(scaleFactor: Float, focusX: Float, focusY: Float) { 115 | if (scale < mMaxScale || scaleFactor < 1f) { 116 | if (mScaleChangeListener != null) { 117 | mScaleChangeListener!!.onScaleChange(scaleFactor, focusX, focusY) 118 | } 119 | mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY) 120 | checkAndDisplayMatrix() 121 | } 122 | } 123 | } 124 | 125 | fun setOnDoubleTapListener(newOnDoubleTapListener: OnDoubleTapListener?) { 126 | mGestureDetector!!.setOnDoubleTapListener(newOnDoubleTapListener) 127 | } 128 | 129 | fun setOnScaleChangeListener(onScaleChangeListener: OnScaleChangedListener?) { 130 | mScaleChangeListener = onScaleChangeListener 131 | } 132 | 133 | fun setOnSingleFlingListener(onSingleFlingListener: OnSingleFlingListener?) { 134 | mSingleFlingListener = onSingleFlingListener 135 | } 136 | 137 | val displayRect: RectF? 138 | get() { 139 | checkMatrixBounds() 140 | return getDisplayRect(drawMatrix) 141 | } 142 | 143 | fun setDisplayMatrix(finalMatrix: Matrix?): Boolean { 144 | requireNotNull(finalMatrix) { "Matrix cannot be null" } 145 | if (mImageView.drawable == null) { 146 | return false 147 | } 148 | mSuppMatrix.set(finalMatrix) 149 | checkAndDisplayMatrix() 150 | return true 151 | } 152 | 153 | fun setBaseRotation(degrees: Float) { 154 | mBaseRotation = degrees % 360 155 | update() 156 | setRotationBy(mBaseRotation) 157 | checkAndDisplayMatrix() 158 | } 159 | 160 | fun setRotationTo(degrees: Float) { 161 | mSuppMatrix.setRotate(degrees % 360) 162 | checkAndDisplayMatrix() 163 | } 164 | 165 | fun setRotationBy(degrees: Float) { 166 | mSuppMatrix.postRotate(degrees % 360) 167 | checkAndDisplayMatrix() 168 | } 169 | 170 | var minimumScale: Float 171 | get() = mMinScale 172 | set(minimumScale) { 173 | checkZoomLevels(minimumScale, mMidScale, mMaxScale) 174 | mMinScale = minimumScale 175 | } 176 | var mediumScale: Float 177 | get() = mMidScale 178 | set(mediumScale) { 179 | checkZoomLevels(mMinScale, mediumScale, mMaxScale) 180 | mMidScale = mediumScale 181 | } 182 | var maximumScale: Float 183 | get() = mMaxScale 184 | set(maximumScale) { 185 | checkZoomLevels(mMinScale, mMidScale, maximumScale) 186 | mMaxScale = maximumScale 187 | } 188 | var scale: Float 189 | get() = sqrt( 190 | getValue(mSuppMatrix, Matrix.MSCALE_X).toDouble().pow(2.0).toFloat() + Math.pow( 191 | getValue( 192 | mSuppMatrix, 193 | Matrix.MSKEW_Y 194 | ).toDouble(), 2.0 195 | ) 196 | ).toFloat() 197 | set(scale) { 198 | setScale(scale, false) 199 | } 200 | var scaleType: ScaleType 201 | get() = mScaleType 202 | set(scaleType) { 203 | if (isSupportedScaleType(scaleType) && scaleType != mScaleType) { 204 | mScaleType = scaleType 205 | update() 206 | } 207 | } 208 | 209 | override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { 210 | // Update our base matrix, as the bounds have changed 211 | if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { 212 | updateBaseMatrix(mImageView.drawable) 213 | } 214 | } 215 | 216 | override fun onTouch(v: View, ev: MotionEvent): Boolean { 217 | var handled = false 218 | if (isZoomEnabled && hasDrawable((v as ImageView))) { 219 | when (ev.action) { 220 | MotionEvent.ACTION_DOWN -> { 221 | val parent = v.getParent() 222 | // First, disable the Parent from intercepting the touch 223 | // event 224 | parent?.requestDisallowInterceptTouchEvent(true) 225 | // If we're flinging, and the user presses down, cancel 226 | // fling 227 | cancelFling() 228 | } 229 | MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> // If the user has zoomed less than min scale, zoom back 230 | // to min scale 231 | if (scale < mMinScale) { 232 | val rect = displayRect 233 | if (rect != null) { 234 | v.post( 235 | AnimatedZoomRunnable( 236 | scale, mMinScale, 237 | rect.centerX(), rect.centerY() 238 | ) 239 | ) 240 | handled = true 241 | } 242 | } else if (scale > mMaxScale) { 243 | val rect = displayRect 244 | if (rect != null) { 245 | v.post( 246 | AnimatedZoomRunnable( 247 | scale, mMaxScale, 248 | rect.centerX(), rect.centerY() 249 | ) 250 | ) 251 | handled = true 252 | } 253 | } 254 | } 255 | // Try the Scale/Drag detector 256 | val wasScaling: Boolean = mScaleDragDetector.isScaling 257 | val wasDragging: Boolean = mScaleDragDetector.isDragging 258 | handled = mScaleDragDetector.onTouchEvent(ev) 259 | val didntScale = !wasScaling && !mScaleDragDetector.isScaling 260 | val didntDrag = !wasDragging && !mScaleDragDetector.isDragging 261 | mBlockParentIntercept = didntScale && didntDrag 262 | // Check to see if the user double tapped 263 | if (mGestureDetector.onTouchEvent(ev)) { 264 | handled = true 265 | } 266 | } 267 | return handled 268 | } 269 | 270 | fun setAllowParentInterceptOnEdge(allow: Boolean) { 271 | mAllowParentInterceptOnEdge = allow 272 | } 273 | 274 | fun setScaleLevels(minimumScale: Float, mediumScale: Float, maximumScale: Float) { 275 | checkZoomLevels(minimumScale, mediumScale, maximumScale) 276 | mMinScale = minimumScale 277 | mMidScale = mediumScale 278 | mMaxScale = maximumScale 279 | } 280 | 281 | fun setOnLongClickListener(listener: OnLongClickListener?) { 282 | mLongClickListener = listener 283 | } 284 | 285 | fun setOnClickListener(listener: View.OnClickListener?) { 286 | mOnClickListener = listener 287 | } 288 | 289 | fun setOnMatrixChangeListener(listener: OnMatrixChangedListener?) { 290 | mMatrixChangeListener = listener 291 | } 292 | 293 | fun setOnPhotoTapListener(listener: OnPhotoTapListener?) { 294 | mPhotoTapListener = listener 295 | } 296 | 297 | fun setOnOutsidePhotoTapListener(mOutsidePhotoTapListener: OnOutsidePhotoTapListener?) { 298 | this.mOutsidePhotoTapListener = mOutsidePhotoTapListener 299 | } 300 | 301 | fun setOnViewTapListener(listener: OnViewTapListener?) { 302 | mViewTapListener = listener 303 | } 304 | 305 | fun setOnViewDragListener(listener: OnViewDragListener?) { 306 | mOnViewDragListener = listener 307 | } 308 | 309 | fun setScale(scale: Float, animate: Boolean) { 310 | setScale( 311 | scale, 312 | mImageView.right / 2.toFloat(), 313 | mImageView.bottom / 2.toFloat(), 314 | animate 315 | ) 316 | } 317 | 318 | fun setScale( 319 | scale: Float, focalX: Float, focalY: Float, 320 | animate: Boolean 321 | ) { 322 | // Check to see if the scale is within bounds 323 | require(!(scale < mMinScale || scale > mMaxScale)) { "Scale must be within the range of minScale and maxScale" } 324 | if (animate) { 325 | mImageView.post( 326 | AnimatedZoomRunnable( 327 | scale, scale, 328 | focalX, focalY 329 | ) 330 | ) 331 | } else { 332 | mSuppMatrix.setScale(scale, scale, focalX, focalY) 333 | checkAndDisplayMatrix() 334 | } 335 | } 336 | 337 | /** 338 | * Set the zoom interpolator 339 | * 340 | * @param interpolator the zoom interpolator 341 | */ 342 | fun setZoomInterpolator(interpolator: Interpolator) { 343 | mInterpolator = interpolator 344 | } 345 | 346 | var isZoomable: Boolean 347 | get() = isZoomEnabled 348 | set(zoomable) { 349 | isZoomEnabled = zoomable 350 | update() 351 | } 352 | 353 | fun update() { 354 | if (isZoomEnabled) { 355 | // Update the base matrix using the current drawable 356 | updateBaseMatrix(mImageView.drawable) 357 | } else { 358 | // Reset the Matrix... 359 | resetMatrix() 360 | } 361 | } 362 | 363 | /** 364 | * Get the display matrix 365 | * 366 | * @param matrix target matrix to copy to 367 | */ 368 | fun getDisplayMatrix(matrix: Matrix) { 369 | matrix.set(drawMatrix) 370 | } 371 | 372 | /** 373 | * Get the current support matrix 374 | */ 375 | fun getSuppMatrix(matrix: Matrix) { 376 | matrix.set(mSuppMatrix) 377 | } 378 | 379 | private val drawMatrix: Matrix 380 | private get() { 381 | imageMatrix.set(mBaseMatrix) 382 | imageMatrix.postConcat(mSuppMatrix) 383 | return imageMatrix 384 | } 385 | 386 | fun setZoomTransitionDuration(milliseconds: Int) { 387 | mZoomDuration = milliseconds 388 | } 389 | 390 | /** 391 | * Helper method that 'unpacks' a Matrix and returns the required value 392 | * 393 | * @param matrix Matrix to unpack 394 | * @param whichValue Which value from Matrix.M* to return 395 | * @return returned value 396 | */ 397 | private fun getValue(matrix: Matrix, whichValue: Int): Float { 398 | matrix.getValues(mMatrixValues) 399 | return mMatrixValues[whichValue] 400 | } 401 | 402 | /** 403 | * Resets the Matrix back to FIT_CENTER, and then displays its contents 404 | */ 405 | private fun resetMatrix() { 406 | mSuppMatrix.reset() 407 | setRotationBy(mBaseRotation) 408 | setImageViewMatrix(drawMatrix) 409 | checkMatrixBounds() 410 | } 411 | 412 | private fun setImageViewMatrix(matrix: Matrix) { 413 | mImageView.imageMatrix = matrix 414 | // Call MatrixChangedListener if needed 415 | if (mMatrixChangeListener != null) { 416 | val displayRect = getDisplayRect(matrix) 417 | if (displayRect != null) { 418 | mMatrixChangeListener!!.onMatrixChanged(displayRect) 419 | } 420 | } 421 | } 422 | 423 | /** 424 | * Helper method that simply checks the Matrix, and then displays the result 425 | */ 426 | private fun checkAndDisplayMatrix() { 427 | if (checkMatrixBounds()) { 428 | setImageViewMatrix(drawMatrix) 429 | } 430 | } 431 | 432 | /** 433 | * Helper method that maps the supplied Matrix to the current Drawable 434 | * 435 | * @param matrix - Matrix to map Drawable against 436 | * @return RectF - Displayed Rectangle 437 | */ 438 | fun getDisplayRect(matrix: Matrix): RectF? { 439 | val d = mImageView.drawable 440 | if (d != null) { 441 | mDisplayRect[0f, 0f, d.intrinsicWidth.toFloat()] = d.intrinsicHeight.toFloat() 442 | matrix.mapRect(mDisplayRect) 443 | return mDisplayRect 444 | } 445 | return null 446 | } 447 | 448 | /** 449 | * Calculate Matrix for FIT_CENTER 450 | * 451 | * @param drawable - Drawable being displayed 452 | */ 453 | private fun updateBaseMatrix(drawable: Drawable?) { 454 | if (drawable == null) { 455 | return 456 | } 457 | val viewWidth = getImageViewWidth(mImageView).toFloat() 458 | val viewHeight = getImageViewHeight(mImageView).toFloat() 459 | val drawableWidth = drawable.intrinsicWidth 460 | val drawableHeight = drawable.intrinsicHeight 461 | mBaseMatrix.reset() 462 | val widthScale = viewWidth / drawableWidth 463 | val heightScale = viewHeight / drawableHeight 464 | if (mScaleType == ScaleType.CENTER) { 465 | mBaseMatrix.postTranslate( 466 | (viewWidth - drawableWidth) / 2f, 467 | (viewHeight - drawableHeight) / 2f 468 | ) 469 | } else if (mScaleType == ScaleType.CENTER_CROP) { 470 | val scale = Math.max(widthScale, heightScale) 471 | mBaseMatrix.postScale(scale, scale) 472 | mBaseMatrix.postTranslate( 473 | (viewWidth - drawableWidth * scale) / 2f, 474 | (viewHeight - drawableHeight * scale) / 2f 475 | ) 476 | } else if (mScaleType == ScaleType.CENTER_INSIDE) { 477 | val scale = Math.min(1.0f, Math.min(widthScale, heightScale)) 478 | mBaseMatrix.postScale(scale, scale) 479 | mBaseMatrix.postTranslate( 480 | (viewWidth - drawableWidth * scale) / 2f, 481 | (viewHeight - drawableHeight * scale) / 2f 482 | ) 483 | } else { 484 | var mTempSrc = RectF(0f, 0f, drawableWidth.toFloat(), drawableHeight.toFloat()) 485 | val mTempDst = RectF(0f, 0f, viewWidth, viewHeight) 486 | if (mBaseRotation.toInt() % 180 != 0) { 487 | mTempSrc = RectF(0f, 0f, drawableHeight.toFloat(), drawableWidth.toFloat()) 488 | } 489 | when (mScaleType) { 490 | ScaleType.FIT_CENTER -> mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.CENTER) 491 | ScaleType.FIT_START -> mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.START) 492 | ScaleType.FIT_END -> mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.END) 493 | ScaleType.FIT_XY -> mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.FILL) 494 | else -> { 495 | } 496 | } 497 | } 498 | resetMatrix() 499 | } 500 | 501 | private fun checkMatrixBounds(): Boolean { 502 | val rect = getDisplayRect(drawMatrix) ?: return false 503 | val height = rect.height() 504 | val width = rect.width() 505 | var deltaX = 0f 506 | var deltaY = 0f 507 | val viewHeight = getImageViewHeight(mImageView) 508 | if (height <= viewHeight) { 509 | deltaY = when (mScaleType) { 510 | ScaleType.FIT_START -> -rect.top 511 | ScaleType.FIT_END -> viewHeight - height - rect.top 512 | else -> (viewHeight - height) / 2 - rect.top 513 | } 514 | mVerticalScrollEdge = VERTICAL_EDGE_BOTH 515 | } else if (rect.top > 0) { 516 | mVerticalScrollEdge = VERTICAL_EDGE_TOP 517 | deltaY = -rect.top 518 | } else if (rect.bottom < viewHeight) { 519 | mVerticalScrollEdge = VERTICAL_EDGE_BOTTOM 520 | deltaY = viewHeight - rect.bottom 521 | } else { 522 | mVerticalScrollEdge = VERTICAL_EDGE_NONE 523 | } 524 | val viewWidth = getImageViewWidth(mImageView) 525 | if (width <= viewWidth) { 526 | deltaX = when (mScaleType) { 527 | ScaleType.FIT_START -> -rect.left 528 | ScaleType.FIT_END -> viewWidth - width - rect.left 529 | else -> (viewWidth - width) / 2 - rect.left 530 | } 531 | mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH 532 | } else if (rect.left > 0) { 533 | mHorizontalScrollEdge = HORIZONTAL_EDGE_LEFT 534 | deltaX = -rect.left 535 | } else if (rect.right < viewWidth) { 536 | deltaX = viewWidth - rect.right 537 | mHorizontalScrollEdge = HORIZONTAL_EDGE_RIGHT 538 | } else { 539 | mHorizontalScrollEdge = HORIZONTAL_EDGE_NONE 540 | } 541 | // Finally actually translate the matrix 542 | mSuppMatrix.postTranslate(deltaX, deltaY) 543 | return true 544 | } 545 | 546 | private fun getImageViewWidth(imageView: ImageView): Int { 547 | return imageView.width - imageView.paddingLeft - imageView.paddingRight 548 | } 549 | 550 | private fun getImageViewHeight(imageView: ImageView): Int { 551 | return imageView.height - imageView.paddingTop - imageView.paddingBottom 552 | } 553 | 554 | private fun cancelFling() { 555 | if (mCurrentFlingRunnable != null) { 556 | mCurrentFlingRunnable!!.cancelFling() 557 | mCurrentFlingRunnable = null 558 | } 559 | } 560 | 561 | private inner class AnimatedZoomRunnable( 562 | currentZoom: Float, targetZoom: Float, 563 | private val mFocalX: Float, private val mFocalY: Float 564 | ) : Runnable { 565 | private val mStartTime: Long 566 | private val mZoomStart: Float 567 | private val mZoomEnd: Float 568 | override fun run() { 569 | val t = interpolate() 570 | val scale = mZoomStart + t * (mZoomEnd - mZoomStart) 571 | val deltaScale = scale / scale 572 | onGestureListener.onScale(deltaScale, mFocalX, mFocalY) 573 | // We haven't hit our target scale yet, so post ourselves again 574 | if (t < 1f) { 575 | Compat.postOnAnimation(mImageView, this) 576 | } 577 | } 578 | 579 | private fun interpolate(): Float { 580 | var t = 1f * (System.currentTimeMillis() - mStartTime) / mZoomDuration 581 | t = Math.min(1f, t) 582 | t = mInterpolator.getInterpolation(t) 583 | return t 584 | } 585 | 586 | init { 587 | mStartTime = System.currentTimeMillis() 588 | mZoomStart = currentZoom 589 | mZoomEnd = targetZoom 590 | } 591 | } 592 | 593 | private inner class FlingRunnable(context: Context?) : Runnable { 594 | private val mScroller: OverScroller 595 | private var mCurrentX = 0 596 | private var mCurrentY = 0 597 | fun cancelFling() { 598 | mScroller.forceFinished(true) 599 | } 600 | 601 | fun fling( 602 | viewWidth: Int, viewHeight: Int, velocityX: Int, 603 | velocityY: Int 604 | ) { 605 | val rect = displayRect ?: return 606 | val startX = Math.round(-rect.left) 607 | val minX: Int 608 | val maxX: Int 609 | val minY: Int 610 | val maxY: Int 611 | if (viewWidth < rect.width()) { 612 | minX = 0 613 | maxX = Math.round(rect.width() - viewWidth) 614 | } else { 615 | maxX = startX 616 | minX = maxX 617 | } 618 | val startY = Math.round(-rect.top) 619 | if (viewHeight < rect.height()) { 620 | minY = 0 621 | maxY = Math.round(rect.height() - viewHeight) 622 | } else { 623 | maxY = startY 624 | minY = maxY 625 | } 626 | mCurrentX = startX 627 | mCurrentY = startY 628 | // If we actually can move, fling the scroller 629 | if (startX != maxX || startY != maxY) { 630 | mScroller.fling( 631 | startX, startY, velocityX, velocityY, minX, 632 | maxX, minY, maxY, 0, 0 633 | ) 634 | } 635 | } 636 | 637 | override fun run() { 638 | if (mScroller.isFinished) { 639 | return // remaining post that should not be handled 640 | } 641 | if (mScroller.computeScrollOffset()) { 642 | val newX = mScroller.currX 643 | val newY = mScroller.currY 644 | mSuppMatrix.postTranslate(mCurrentX - newX.toFloat(), mCurrentY - newY.toFloat()) 645 | checkAndDisplayMatrix() 646 | mCurrentX = newX 647 | mCurrentY = newY 648 | // Post On animation 649 | Compat.postOnAnimation(mImageView, this) 650 | } 651 | } 652 | 653 | init { 654 | mScroller = OverScroller(context) 655 | } 656 | } 657 | 658 | companion object { 659 | private const val DEFAULT_MAX_SCALE = 3.0f 660 | private const val DEFAULT_MID_SCALE = 1.75f 661 | private const val DEFAULT_MIN_SCALE = 1.0f 662 | private const val DEFAULT_ZOOM_DURATION = 200 663 | private const val HORIZONTAL_EDGE_NONE = -1 664 | private const val HORIZONTAL_EDGE_LEFT = 0 665 | private const val HORIZONTAL_EDGE_RIGHT = 1 666 | private const val HORIZONTAL_EDGE_BOTH = 2 667 | private const val VERTICAL_EDGE_NONE = -1 668 | private const val VERTICAL_EDGE_TOP = 0 669 | private const val VERTICAL_EDGE_BOTTOM = 1 670 | private const val VERTICAL_EDGE_BOTH = 2 671 | private const val SINGLE_TOUCH = 1 672 | } 673 | 674 | init { 675 | mImageView.setOnTouchListener(this) 676 | mImageView.addOnLayoutChangeListener(this) 677 | if (!mImageView.isInEditMode) { 678 | mBaseRotation = 0.0f 679 | // Create Gesture Detectors... 680 | mScaleDragDetector = CustomGestureDetector(mImageView.context, onGestureListener) 681 | mGestureDetector = GestureDetector(mImageView.context, object : SimpleOnGestureListener() { 682 | // forward long click listener 683 | override fun onLongPress(e: MotionEvent) { 684 | if (mLongClickListener != null) { 685 | mLongClickListener!!.onLongClick(mImageView) 686 | } 687 | } 688 | 689 | override fun onFling( 690 | e1: MotionEvent, e2: MotionEvent, 691 | velocityX: Float, velocityY: Float 692 | ): Boolean { 693 | if (mSingleFlingListener != null) { 694 | if (scale > DEFAULT_MIN_SCALE) { 695 | return false 696 | } 697 | return if (e1.pointerCount > SINGLE_TOUCH 698 | || e2.pointerCount > SINGLE_TOUCH 699 | ) { 700 | false 701 | } else mSingleFlingListener!!.onFling(e1, e2, velocityX, velocityY) 702 | } 703 | return false 704 | } 705 | }) 706 | mGestureDetector.setOnDoubleTapListener(object : OnDoubleTapListener { 707 | override fun onSingleTapConfirmed(e: MotionEvent): Boolean { 708 | if (mOnClickListener != null) { 709 | mOnClickListener!!.onClick(mImageView) 710 | } 711 | val displayRect = displayRect 712 | val x = e.x 713 | val y = e.y 714 | if (mViewTapListener != null) { 715 | mViewTapListener!!.onViewTap(mImageView, x, y) 716 | } 717 | if (displayRect != null) { 718 | // Check to see if the user tapped on the photo 719 | if (displayRect.contains(x, y)) { 720 | val xResult = ((x - displayRect.left) 721 | / displayRect.width()) 722 | val yResult = ((y - displayRect.top) 723 | / displayRect.height()) 724 | if (mPhotoTapListener != null) { 725 | mPhotoTapListener!!.onPhotoTap(mImageView, xResult, yResult) 726 | } 727 | return true 728 | } else { 729 | if (mOutsidePhotoTapListener != null) { 730 | mOutsidePhotoTapListener!!.onOutsidePhotoTap(mImageView) 731 | } 732 | } 733 | } 734 | return false 735 | } 736 | 737 | override fun onDoubleTap(ev: MotionEvent): Boolean { 738 | try { 739 | val scale = scale 740 | val x = ev.x 741 | val y = ev.y 742 | if (scale < mediumScale) { 743 | setScale(mediumScale, x, y, true) 744 | } else if (scale >= mediumScale && scale < maximumScale) { 745 | setScale(maximumScale, x, y, true) 746 | } else { 747 | setScale(minimumScale, x, y, true) 748 | } 749 | } catch (e: ArrayIndexOutOfBoundsException) { 750 | // Can sometimes happen when getX() and getY() is called 751 | } 752 | return true 753 | } 754 | 755 | override fun onDoubleTapEvent(e: MotionEvent): Boolean { 756 | // Wait for the confirmed onDoubleTap() instead 757 | return false 758 | } 759 | }) 760 | } 761 | } 762 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/photoview/Util.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.photoview 2 | 3 | import android.view.MotionEvent 4 | import android.widget.ImageView 5 | import android.widget.ImageView.ScaleType 6 | 7 | internal object Util { 8 | @JvmStatic 9 | fun checkZoomLevels( 10 | minZoom: Float, midZoom: Float, 11 | maxZoom: Float 12 | ) { 13 | require(minZoom < midZoom) { "Minimum zoom has to be less than Medium zoom. Call setMinimumZoom() with a more appropriate value" } 14 | require(midZoom < maxZoom) { "Medium zoom has to be less than Maximum zoom. Call setMaximumZoom() with a more appropriate value" } 15 | } 16 | 17 | @JvmStatic 18 | fun hasDrawable(imageView: ImageView): Boolean { 19 | return imageView.drawable != null 20 | } 21 | 22 | @JvmStatic 23 | fun isSupportedScaleType(scaleType: ScaleType?): Boolean { 24 | if (scaleType == null) { 25 | return false 26 | } 27 | when (scaleType) { 28 | ScaleType.MATRIX -> throw IllegalStateException("Matrix scale type is not supported") 29 | } 30 | return true 31 | } 32 | 33 | fun getPointerIndex(action: Int): Int { 34 | return action and MotionEvent.ACTION_POINTER_INDEX_MASK shr MotionEvent.ACTION_POINTER_INDEX_SHIFT 35 | } 36 | } -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/utils/MimeType.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.utils 2 | 3 | /** 4 | * @author DeMon 5 | * Created on 2020/10/23. 6 | * E-mail idemon_liu@qq.com 7 | * Desc: Android常用MimeType,参考:https://www.w3school.com.cn/media/media_mimeref.asp 8 | */ 9 | object MimeType { 10 | const val all = "*/*" 11 | const val img = "image/*" 12 | const val video = "video/*" 13 | const val audio = "audio/*" 14 | const val text = "text/*" 15 | const val apk = "application/vnd.android.package-archive" 16 | const val zip = "application/x-zip-compressed" 17 | const val pdf = "application/pdf" 18 | 19 | /** 20 | * Word 21 | */ 22 | const val doc = "application/msword" 23 | const val docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" 24 | 25 | /** 26 | * PPT 27 | */ 28 | const val ppt = "application/vnd.ms-powerpoint" 29 | const val pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation" 30 | 31 | /** 32 | * Excel 33 | */ 34 | const val xls = "application/vnd.ms-excel" 35 | const val xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 36 | const val _png = "image/png" 37 | const val _jpeg = "image/jpeg" 38 | const val _jpg = "image/jpeg" 39 | const val _webp = "image/webp" 40 | const val _gif = "image/gif" 41 | const val _bmp = "image/bmp" 42 | const val _3gp = "video/3gpp" 43 | const val _asf = "video/x-ms-asf" 44 | const val _avi = "video/x-msvideo" 45 | const val _bin = "application/octet-stream" 46 | const val _c = "text/plain" 47 | const val _class = "application/octet-stream" 48 | const val _conf = "text/plain" 49 | const val _cpp = "text/plain" 50 | const val _exe = "application/octet-stream" 51 | const val _gtar = "application/x-gtar" 52 | const val _gz = "application/x-gzip" 53 | const val _h = "text/plain" 54 | const val _htm = "text/html" 55 | const val _html = "text/html" 56 | const val _jar = "application/java-archive" 57 | const val _java = "text/plain" 58 | const val _js = "application/x-javascript" 59 | const val _log = "text/plain" 60 | const val _m3u = "audio/x-mpegurl" 61 | const val _m4a = "audio/mp4a-latm" 62 | const val _m4b = "audio/mp4a-latm" 63 | const val _m4p = "audio/mp4a-latm" 64 | const val _m4u = "video/vnd.mpegurl" 65 | const val _m4v = "video/x-m4v" 66 | const val _mov = "video/quicktime" 67 | const val _mp2 = "audio/x-mpeg" 68 | const val _mp3 = "audio/x-mpeg" 69 | const val _mp4 = "video/mp4" 70 | const val _mpc = "application/vnd.mpohun.certificate" 71 | const val _mpe = "video/mpeg" 72 | const val _mpeg = "video/mpeg" 73 | const val _mpg = "video/mpeg" 74 | const val _mpg4 = "video/mp4" 75 | const val _mpga = "audio/mpeg" 76 | const val _msg = "application/vnd.ms-outlook" 77 | const val _ogg = "audio/ogg" 78 | const val _pps = "application/vnd.ms-powerpoint" 79 | const val _prop = "text/plain" 80 | const val _rc = "text/plain" 81 | const val _rmvb = "audio/x-pn-realaudio" 82 | const val _rtf = "application/rtf" 83 | const val _sh = "text/plain" 84 | const val _tar = "application/x-tar" 85 | const val _tgz = "application/x-compressed" 86 | const val _txt = "text/plain" 87 | const val _wav = "audio/x-wav" 88 | const val _wma = "audio/x-ms-wma" 89 | const val _wmv = "audio/x-ms-wmv" 90 | const val _wps = "application/vnd.ms-works" 91 | const val _xml = "text/plain" 92 | const val _z = "application/x-compress" 93 | 94 | } 95 | 96 | fun String.isWord() = this == MimeType.doc || this == MimeType.docx 97 | 98 | fun String.isPPT() = this == MimeType.ppt || this == MimeType.pptx 99 | 100 | fun String.isExcel() = this == MimeType.xls || this == MimeType.xlsx 101 | 102 | fun String.isImage() = this.startsWith("image/") 103 | 104 | fun String.isGif() = this == MimeType._gif 105 | 106 | fun String.isVideo() = this.startsWith("video/") 107 | 108 | fun String.isAudio() = this.startsWith("audio/") 109 | 110 | fun String.isTxt() = this.startsWith("text/") 111 | 112 | fun String.isPdf() = this == MimeType.pdf 113 | 114 | fun String.isApk() = this == MimeType.apk 115 | 116 | fun String.isZip() = this == MimeType.zip 117 | || this == MimeType._tar 118 | || this == MimeType._tgz 119 | || this == MimeType._z 120 | || this == MimeType._gtar 121 | || this == MimeType._gz 122 | 123 | 124 | -------------------------------------------------------------------------------- /solution/src/main/java/com/demon/qfsolution/utils/QFileExt.kt: -------------------------------------------------------------------------------- 1 | package com.demon.qfsolution.utils 2 | 3 | import android.content.* 4 | import android.content.pm.PackageManager 5 | import android.content.res.AssetFileDescriptor 6 | import android.database.Cursor 7 | import android.graphics.Bitmap 8 | import android.media.MediaScannerConnection 9 | import android.net.Uri 10 | import android.os.Build 11 | import android.os.Environment 12 | import android.os.FileUtils 13 | import android.provider.DocumentsContract 14 | import android.provider.MediaStore 15 | import android.text.TextUtils 16 | import android.util.Log 17 | import android.webkit.MimeTypeMap 18 | import androidx.core.content.FileProvider 19 | import androidx.documentfile.provider.DocumentFile 20 | import androidx.fragment.app.Fragment 21 | import androidx.fragment.app.FragmentActivity 22 | import com.demon.qfsolution.QFHelper 23 | import com.demon.qfsolution.fragment.QFGhostFragment 24 | import kotlinx.coroutines.suspendCancellableCoroutine 25 | import java.io.* 26 | import java.net.URLConnection 27 | 28 | 29 | /** 30 | * @author DeMon 31 | * Created on 2020/10/23. 32 | * E-mail idemon_liu@qq.com 33 | * Desc: 34 | */ 35 | 36 | /** 37 | * Activity中使用相机拍照 38 | * 根据泛型类型返回结果: 39 | * Uri:图片的Uri 40 | * File:图片的文件对象 41 | * String:图片的绝对路径 42 | * 43 | * @param fileName 拍照后的文件名,默认为空 取时间戳.jpg 44 | */ 45 | suspend inline fun FragmentActivity.gotoCamera(fileName: String? = null): T? { 46 | return suspendCancellableCoroutine { continuation -> 47 | runCatching { 48 | val name = fileName ?: "${System.currentTimeMillis()}.jpg" 49 | val file = getFileInPublicDir(name, Environment.DIRECTORY_DCIM) 50 | val uri = file.getFileUri() 51 | val intentCamera = Intent(MediaStore.ACTION_IMAGE_CAPTURE) 52 | intentCamera.putExtra(MediaStore.EXTRA_OUTPUT, uri) 53 | intentCamera.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 54 | val fm = supportFragmentManager 55 | val fragment = QFGhostFragment() 56 | fragment.init(intentCamera) { 57 | //更新图库 58 | MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null) 59 | when (T::class.java) { 60 | File::class.java -> { 61 | continuation.resumeWith(Result.success(file as T)) 62 | } 63 | 64 | Uri::class.java -> { 65 | continuation.resumeWith(Result.success(uri as T)) 66 | } 67 | 68 | String::class.java -> { 69 | continuation.resumeWith(Result.success(file.absolutePath as T)) 70 | } 71 | 72 | else -> { 73 | Log.e("FileExt", "gotoCamera:Result only support File,Uri,String!") 74 | continuation.resumeWith(Result.success(null)) 75 | } 76 | } 77 | fm.beginTransaction().remove(fragment).commitAllowingStateLoss() 78 | } 79 | fm.beginTransaction().add(fragment, QFGhostFragment::class.java.simpleName).commitAllowingStateLoss() 80 | }.onFailure { 81 | it.printStackTrace() 82 | continuation.resumeWith(Result.success(null)) 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Fragment中使用相机拍照 89 | * @param fileName 文件名,默认为空取时间戳 90 | */ 91 | suspend inline fun Fragment.gotoCamera(fileName: String? = null): T? { 92 | val activity = requireActivity() 93 | return activity.gotoCamera(fileName) 94 | } 95 | 96 | /** 97 | * Activity中使用打开系统相册 98 | * 根据泛型类型返回结果: 99 | * Uri:文件的Uri 100 | * File:文件的File对象 101 | * String:文件的绝对路径 102 | */ 103 | suspend inline fun FragmentActivity.openPhotoAlbum(): T? { 104 | return suspendCancellableCoroutine { continuation -> 105 | runCatching { 106 | val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) 107 | intent.type = MimeType.img 108 | val fm = supportFragmentManager 109 | val fragment = QFGhostFragment() 110 | fragment.init(intent) { 111 | val uri = it?.data 112 | when (T::class.java) { 113 | File::class.java -> { 114 | val file = uri?.uriToFile() 115 | file?.run { 116 | continuation.resumeWith(Result.success(this as T)) 117 | } ?: continuation.resumeWith(Result.success(null)) 118 | } 119 | 120 | Uri::class.java -> { 121 | continuation.resumeWith(Result.success(uri as T)) 122 | } 123 | 124 | String::class.java -> { 125 | val file = uri?.uriToFile() 126 | file?.run { 127 | continuation.resumeWith(Result.success(absolutePath as T)) 128 | } ?: continuation.resumeWith(Result.success(null)) 129 | } 130 | 131 | else -> { 132 | Log.e("FileExt", "openFile: Result only support File,Uri,String!") 133 | continuation.resumeWith(Result.success(null)) 134 | } 135 | } 136 | fm.beginTransaction().remove(fragment).commitAllowingStateLoss() 137 | } 138 | fm.beginTransaction().add(fragment, QFGhostFragment::class.java.simpleName).commitAllowingStateLoss() 139 | }.onFailure { 140 | it.printStackTrace() 141 | continuation.resumeWith(Result.success(null)) 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Fragment中使用打开系统相册 148 | */ 149 | suspend inline fun Fragment.openPhotoAlbum(): T? { 150 | val activity = requireActivity() 151 | return activity.openPhotoAlbum() 152 | } 153 | 154 | /** 155 | * Activity中使用打开系统文件选择 156 | * 根据泛型类型返回结果: 157 | * Uri:文件的Uri 158 | * File:文件的File对象 159 | * String:文件的绝对路径 160 | * @param mimeTypes 文件[MimeType],默认全部MimeType.all。也可多种类型如图片&文本:arrayListOf(MimeType.img, MimeType.text) 161 | */ 162 | suspend inline fun FragmentActivity.openFile(mimeTypes: List = arrayListOf(MimeType.all)): T? { 163 | return suspendCancellableCoroutine { continuation -> 164 | runCatching { 165 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) 166 | intent.addCategory(Intent.CATEGORY_OPENABLE) 167 | intent.type = "*/*" 168 | val arrays = arrayOfNulls(mimeTypes.size) 169 | for (i in mimeTypes.indices) { 170 | arrays[i] = mimeTypes[i] 171 | } 172 | if (arrays.isNotEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, arrays) 173 | val fm = supportFragmentManager 174 | val fragment = QFGhostFragment() 175 | fragment.init(intent) { 176 | val uri = it?.data 177 | when (T::class.java) { 178 | File::class.java -> { 179 | val file = uri?.uriToFile() 180 | file?.run { 181 | continuation.resumeWith(Result.success(this as T)) 182 | } ?: continuation.resumeWith(Result.success(null)) 183 | } 184 | 185 | Uri::class.java -> { 186 | continuation.resumeWith(Result.success(uri as T)) 187 | } 188 | 189 | String::class.java -> { 190 | val file = uri?.uriToFile() 191 | file?.run { 192 | continuation.resumeWith(Result.success(absolutePath as T)) 193 | } ?: continuation.resumeWith(Result.success(null)) 194 | } 195 | 196 | else -> { 197 | Log.e("FileExt", "openFile: Result only support File,Uri,String!") 198 | continuation.resumeWith(Result.success(null)) 199 | } 200 | } 201 | fm.beginTransaction().remove(fragment).commitAllowingStateLoss() 202 | } 203 | fm.beginTransaction().add(fragment, QFGhostFragment::class.java.simpleName).commitAllowingStateLoss() 204 | }.onFailure { 205 | it.printStackTrace() 206 | continuation.resumeWith(Result.success(null)) 207 | } 208 | } 209 | } 210 | 211 | /** 212 | * Fragment中使用打开文件选择 213 | * @param mimeTypes 文件[MimeType],默认全部MimeType.all。也可多种类型如图片&文本:arrayListOf(MimeType.img, MimeType.text) 214 | */ 215 | suspend inline fun Fragment.openFile(mimeTypes: List = arrayListOf(MimeType.all)): T? { 216 | val activity = requireActivity() 217 | return activity.openFile(mimeTypes) 218 | } 219 | 220 | /** 221 | * Activity中使用原生比例裁剪 222 | * @param uri content://URI格式的Uri文件 223 | * @param width 宽,用于计算裁剪比例 224 | * @param height 高,用于计算裁剪比例 225 | * @param fileName 裁剪后的文件名,默认为空 取时间戳.png 226 | */ 227 | suspend inline fun FragmentActivity.startCrop(uri: Uri, width: Int, height: Int, fileName: String? = null): T? { 228 | return suspendCancellableCoroutine { continuation -> 229 | runCatching { 230 | val name = fileName ?: "${System.currentTimeMillis()}.png" 231 | val file = getFileInPublicDir(name, Environment.DIRECTORY_PICTURES) 232 | val cropUri = file.getFileUri() 233 | val intentCrop = Intent("com.android.camera.action.CROP") 234 | intentCrop.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 235 | intentCrop.setDataAndType(uri, "image/*") 236 | //下面这个crop=true是设置在开启的Intent中设置显示的VIEW可裁剪 237 | intentCrop.putExtra("crop", "true") 238 | //裁剪时是否保留图片的比例 239 | intentCrop.putExtra("scale", true) 240 | if (width != -1 && height != -1) { 241 | // aspectX aspectY 是宽高的比例 242 | intentCrop.putExtra("aspectX", width) 243 | intentCrop.putExtra("aspectY", height) 244 | // outputX outputY 是裁剪图片宽高 245 | intentCrop.putExtra("outputX", width) 246 | intentCrop.putExtra("outputY", height) 247 | } 248 | //是否将数据保留在Bitmap中返回 249 | intentCrop.putExtra("return-data", false) 250 | //关闭人脸识别 251 | intentCrop.putExtra("noFaceDetection", true) 252 | //设置输出的格式 253 | intentCrop.putExtra("outputFormat", Bitmap.CompressFormat.PNG.toString()) 254 | //裁剪后uri无法保存的问题 255 | cropUri.grantPermissions(this, intentCrop) 256 | intentCrop.putExtra(MediaStore.EXTRA_OUTPUT, cropUri) 257 | val fm = supportFragmentManager 258 | val fragment = QFGhostFragment() 259 | fragment.init(intentCrop) { 260 | //更新图库 261 | MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null) 262 | when (T::class.java) { 263 | File::class.java -> { 264 | continuation.resumeWith(Result.success(cropUri.uriToFile() as T)) 265 | } 266 | 267 | Uri::class.java -> { 268 | continuation.resumeWith(Result.success(cropUri as T)) 269 | } 270 | 271 | String::class.java -> { 272 | continuation.resumeWith(Result.success(cropUri.uriToFile()?.absolutePath as T)) 273 | } 274 | 275 | else -> { 276 | Log.e("FileExt", "gotoCamera:Result only support File,Uri,String!") 277 | continuation.resumeWith(Result.success(null)) 278 | } 279 | } 280 | fm.beginTransaction().remove(fragment).commitAllowingStateLoss() 281 | } 282 | fm.beginTransaction().add(fragment, QFGhostFragment::class.java.simpleName).commitAllowingStateLoss() 283 | }.onFailure { 284 | it.printStackTrace() 285 | continuation.resumeWith(Result.success(null)) 286 | } 287 | } 288 | } 289 | 290 | suspend inline fun Fragment.startCrop(uri: Uri, width: Int, height: Int, fileName: String? = null): T? { 291 | val activity = requireActivity() 292 | return activity.startCrop(uri, width, height, fileName) 293 | } 294 | 295 | /** 296 | * Activity中使用原生自由裁剪 297 | * width&height同时设置为-1,则是自由裁剪 298 | * @param uri content://URI格式的Uri文件 299 | * @param isSave 是否保存至相册,默认true 300 | * @param fileName 裁剪后的文件名,默认为空 取时间戳.png 301 | */ 302 | suspend inline fun FragmentActivity.startCrop(uri: Uri, fileName: String? = null): T? { 303 | return startCrop(uri, -1, -1, fileName) 304 | } 305 | 306 | suspend inline fun Fragment.startCrop(uri: Uri, fileName: String? = null): T? { 307 | val activity = requireActivity() 308 | return activity.startCrop(uri, fileName) 309 | } 310 | 311 | 312 | /** 313 | * 将Uri转为File 314 | */ 315 | fun Uri?.uriToFile(): File? { 316 | this ?: return null 317 | qFilelog("uriToFile: $this") 318 | return when (scheme) { 319 | ContentResolver.SCHEME_FILE -> { 320 | File(this.path) 321 | } 322 | 323 | ContentResolver.SCHEME_CONTENT -> { 324 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 325 | getFileFromUriQ() 326 | } else { 327 | getFileFromUriN() 328 | } 329 | } 330 | 331 | else -> { 332 | File(toString()) 333 | } 334 | } 335 | } 336 | 337 | /** 338 | * 根据Uri获取File,AndroidQ及以上可用 339 | * AndroidQ中只有沙盒中的文件可以直接根据绝对路径获取File,非沙盒环境是无法根据绝对路径访问的 340 | * 因此先判断Uri是否是沙盒中的文件,如果是直接拼接绝对路径访问,否则使用[saveFileByUri]复制到沙盒中生成File 341 | */ 342 | fun Uri.getFileFromUriQ(): File? { 343 | QFHelper.assertNotInit() 344 | var file: File? = getFileFromMedia() 345 | if (file == null) { 346 | file = getFileFromDocuments() 347 | } 348 | return if (!file.isFileExists()) { 349 | this.saveFileByUri() 350 | } else { 351 | file 352 | } 353 | } 354 | 355 | /** 356 | * 根据Uri获取File,AndroidN~AndroidQ可用 357 | */ 358 | fun Uri.getFileFromUriN(): File? { 359 | QFHelper.assertNotInit() 360 | var file = getFileFromMedia() 361 | val uri = this 362 | qFilelog("getFileFromUriN: $uri ${uri.authority} ${uri.path}") 363 | val authority = uri.authority 364 | val path = uri.path 365 | /** 366 | * fileProvider{@xml/file_paths}授权的Uri 367 | */ 368 | if (file == null && authority != null && authority.startsWith(QFHelper.context.packageName) && path != null) { 369 | //这里的值来自你的provider_paths.xml,如果不同需要自己进行添加修改 370 | val externals = mutableListOf( 371 | "/external", 372 | "/external_path", 373 | "/beta_external_files_path", 374 | "/external_cache_path", 375 | "/beta_external_path", 376 | "/external_files", 377 | "/internal" 378 | ) 379 | externals.forEach { 380 | if (path.startsWith(it)) { 381 | //如果你在provider_paths.xml中修改了path,需要自己进行修改 382 | val newFile = File("${Environment.getExternalStorageDirectory().absolutePath}/${path.replace(it, "")}") 383 | if (newFile.exists()) { 384 | file = newFile 385 | } 386 | } 387 | } 388 | } 389 | /** 390 | * Intent.ACTION_OPEN_DOCUMENT选择的文件Uri 391 | */ 392 | if (file == null) { 393 | file = getFileFromDocuments() 394 | } 395 | return if (!file.isFileExists()) { 396 | //形如content://com.android.providers.downloads.documents/document/582的下载内容中的文件 397 | //无法根据Uri获取到真实路径的文件,统一使用saveFileByUri()方法获取File 398 | uri.saveFileByUri() 399 | } else { 400 | file 401 | } 402 | } 403 | 404 | /** 405 | * media类型的Uri,相册中选择得到的uri, 406 | * 形如content://media/external/images/media/11560 407 | */ 408 | fun Uri.getFileFromMedia(): File? { 409 | var file: File? = null 410 | val authority = this.authority ?: "'" 411 | if (authority.startsWith("media")) { 412 | getDataColumn()?.run { 413 | file = File(this) 414 | } 415 | } 416 | return if (file.isFileExists()) { 417 | file 418 | } else { 419 | null 420 | } 421 | } 422 | 423 | /** 424 | * Intent.ACTION_OPEN_DOCUMENT选择的文件Uri 425 | */ 426 | fun Uri.getFileFromDocuments(): File? { 427 | grantPermissions(QFHelper.context) 428 | val uriId = when { 429 | DocumentsContract.isDocumentUri(QFHelper.context, this) -> { 430 | qFilelog("getFileFromDocuments: isDocumentUri") 431 | DocumentsContract.getDocumentId(this) 432 | } 433 | 434 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && DocumentsContract.isTreeUri(this) -> { 435 | qFilelog("getFileFromDocuments: isTreeUri") 436 | DocumentsContract.getTreeDocumentId(this) 437 | } 438 | 439 | else -> null 440 | } 441 | qFilelog("getFileFromDocuments: $uriId") 442 | uriId ?: return null 443 | var file: File? = null 444 | val split: List = uriId.split(":") 445 | if (split.size < 2) return null 446 | when { 447 | //文件存在沙盒中,可直接拼接全路径访问 448 | //判断依据目前是Android/data/包名,不够严谨 449 | split[1].contains("Android/data/${QFHelper.context.packageName}") -> { 450 | file = File("${Environment.getExternalStorageDirectory().absolutePath}/${split[1]}") 451 | } 452 | 453 | isExternalStorageDocument() -> { //内部存储设备中选择 454 | if (split.size > 1) file = File("${Environment.getExternalStorageDirectory().absolutePath}/${split[1]}") 455 | } 456 | 457 | isDownloadsDocument() -> { //下载内容中选择 458 | if (uriId.startsWith("raw:")) { 459 | file = File(split[1]) 460 | } else { 461 | //MediaStore.Downloads.EXTERNAL_CONTENT_URI 462 | } 463 | qFilelog("isDownloadsDocument ${file?.absolutePath}") 464 | //content://com.android.providers.downloads.documents/document/582 465 | } 466 | 467 | isMediaDocument() -> { //多媒体中选择 468 | var contentUri: Uri? = null 469 | when (split[0]) { 470 | "image" -> { 471 | contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 472 | } 473 | 474 | "video" -> { 475 | contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI 476 | } 477 | 478 | "audio" -> { 479 | contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 480 | } 481 | } 482 | qFilelog("isMediaDocument contentUri: $contentUri") 483 | contentUri?.run { 484 | val uri = ContentUris.withAppendedId(this, split[1].toLong()) 485 | qFilelog("isMediaDocument media: $uri") 486 | uri.getDataColumn()?.run { 487 | file = File(this) 488 | } 489 | } 490 | } 491 | } 492 | file ?: return null 493 | 494 | return if (file.isFileExists()) { 495 | file 496 | } else { 497 | null 498 | } 499 | } 500 | 501 | /** 502 | * 根据Uri查询文件路径 503 | * Android4.4之前都可用,Android4.4之后只有从多媒体中选择的文件可用 504 | */ 505 | fun Uri?.getDataColumn(): String? { 506 | QFHelper.assertNotInit() 507 | if (this == null) return null 508 | var str: String? = null 509 | var cursor: Cursor? = null 510 | try { 511 | cursor = QFHelper.context.contentResolver.query(this, arrayOf(MediaStore.MediaColumns.DATA), null, null, null) 512 | cursor?.run { 513 | if (this.moveToFirst()) { 514 | val index = this.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) 515 | if (index != -1) str = this.getString(index) 516 | } 517 | } 518 | } catch (e: Exception) { 519 | e.printStackTrace() 520 | } finally { 521 | cursor?.close() 522 | } 523 | qFilelog("getDataColumn: $str") 524 | return str 525 | } 526 | 527 | /** 528 | * fileProvider 529 | */ 530 | fun File.getFileUri(): Uri { 531 | QFHelper.assertNotInit() 532 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 533 | FileProvider.getUriForFile(QFHelper.context, "${QFHelper.context.packageName}.${QFHelper.authorities}", this) 534 | } else { 535 | Uri.fromFile(this) 536 | } 537 | } 538 | 539 | /** 540 | * 根据Uri将文件保存File到沙盒中 541 | * 此方法能解决部分Uri无法获取到File的问题 542 | * 但是会造成文件冗余,可以根据实际情况,决定是否需要删除 543 | */ 544 | fun Uri.saveFileByUri(): File? { 545 | QFHelper.assertNotInit() 546 | //文件夹uri,不复制直接return null 547 | if (isDirectory()) return null 548 | try { 549 | val inputStream = QFHelper.context.contentResolver.openInputStream(this) 550 | val fileName = this.getFileName() ?: "${System.currentTimeMillis()}.${getExtensionByUri()}" 551 | val file = File(QFHelper.context.getCacheChildDir(null), fileName) 552 | if (!file.exists()) { 553 | file.createNewFile() 554 | } 555 | val fos = FileOutputStream(file) 556 | val bis = BufferedInputStream(inputStream) 557 | val bos = BufferedOutputStream(fos) 558 | val byteArray = ByteArray(1024) 559 | var bytes = bis.read(byteArray) 560 | while (bytes > 0) { 561 | bos.write(byteArray, 0, bytes) 562 | bos.flush() 563 | bytes = bis.read(byteArray) 564 | } 565 | bos.close() 566 | fos.close() 567 | return file 568 | } catch (e: FileNotFoundException) { 569 | e.printStackTrace() 570 | } catch (e: Exception) { 571 | e.printStackTrace() 572 | } 573 | return null 574 | } 575 | 576 | /** 577 | * 将图片保存至相册,兼容AndroidQ 578 | * 579 | * @param name 图片名称 580 | */ 581 | fun File?.saveToAlbum(name: String? = null): Boolean { 582 | QFHelper.assertNotInit() 583 | if (this == null || !isFileExists()) return false 584 | qFilelog("saveToAlbum: ${this.absolutePath}") 585 | runCatching { 586 | val values = ContentValues() 587 | val resolver = QFHelper.context.contentResolver 588 | val fileName = name?.run { 589 | this 590 | } ?: run { 591 | this.name 592 | } 593 | values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) 594 | values.put(MediaStore.MediaColumns.MIME_TYPE, fileName.getMimeTypeByFileName()) 595 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 596 | //AndroidQ更新图库需要将拍照后保存至沙盒的原图copy到系统多媒体 597 | values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) 598 | val saveUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) 599 | if (saveUri != null) { 600 | val out = resolver.openOutputStream(saveUri) 601 | val input = FileInputStream(this) 602 | if (out != null) { 603 | FileUtils.copy(input, out) //直接调用系统方法保存 604 | } 605 | out?.close() 606 | input.close() 607 | } 608 | } else { 609 | //作用域内的文件多媒体无法显示 610 | //会抛异常:UNIQUE constraint failed: files._data (code 2067) 611 | if (this.absolutePath.isAndroidDataFile()) { 612 | val file = getFileInPublicDir(fileName, Environment.DIRECTORY_PICTURES) 613 | //AndroidQ以下作用域的需要将文件复制到公共目录,再插入多媒体中 614 | this.copyFile(file) 615 | values.put(MediaStore.MediaColumns.DATA, file.absolutePath) 616 | } else { 617 | //AndroidQ以下非作用域的直接将文件路径插入多媒体中即可 618 | values.put(MediaStore.MediaColumns.DATA, this.absolutePath) 619 | } 620 | resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) 621 | } 622 | return true 623 | }.onFailure { 624 | it.printStackTrace() 625 | } 626 | return false 627 | } 628 | 629 | fun String?.saveToAlbum(name: String? = null): Boolean { 630 | this ?: return false 631 | return File(this).saveToAlbum(name) 632 | } 633 | 634 | 635 | /** 636 | * Uri授权,解决Android12和部分手机Uri无法读取访问问题 637 | */ 638 | fun Uri?.grantPermissions(context: Context, intent: Intent? = null) { 639 | this ?: return 640 | if (intent == null) { 641 | context.grantUriPermission(context.packageName, this, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) 642 | } else { 643 | val resInfoList = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) 644 | for (resolveInfo in resInfoList) { 645 | val packageName = resolveInfo.activityInfo.packageName 646 | context.grantUriPermission(packageName, this, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) 647 | } 648 | } 649 | } 650 | 651 | /** 652 | * new一个用于保存在公有目录的文件,不会创建空文件,用于拍照,裁剪路径 653 | * 公有目录无需读写权限也可操作媒体文件:图片,适配,音频 654 | * @param name 文件名 655 | * @param dir 公有文件目录 656 | * @see android.os.Environment.DIRECTORY_DOWNLOADS 657 | * @see android.os.Environment.DIRECTORY_DCIM, 658 | * @see android.os.Environment.DIRECTORY_MUSIC, 659 | * @see android.os.Environment.DIRECTORY_PODCASTS, 660 | * @see android.os.Environment.DIRECTORY_RINGTONES, 661 | * @see android.os.Environment.DIRECTORY_ALARMS, 662 | * @see android.os.Environment.DIRECTORY_NOTIFICATIONS, 663 | * @see android.os.Environment.DIRECTORY_PICTURES, 664 | * @see android.os.Environment.DIRECTORY_MOVIES, 665 | * @see android.os.Environment.DIRECTORY_DOCUMENTS 666 | */ 667 | fun getFileInPublicDir(name: String, type: String = Environment.DIRECTORY_DOCUMENTS): File { 668 | return File(Environment.getExternalStoragePublicDirectory(type), name) 669 | } 670 | 671 | /** 672 | * 创建用于保存在公有目录的文件uri,会创建空文件 673 | * @param name 文件名 674 | * @param dir 公有文件目录 675 | * @see android.os.Environment.DIRECTORY_DOWNLOADS 676 | * @see android.os.Environment.DIRECTORY_DCIM, 677 | * @see android.os.Environment.DIRECTORY_MUSIC, 678 | * @see android.os.Environment.DIRECTORY_PODCASTS, 679 | * @see android.os.Environment.DIRECTORY_RINGTONES, 680 | * @see android.os.Environment.DIRECTORY_ALARMS, 681 | * @see android.os.Environment.DIRECTORY_NOTIFICATIONS, 682 | * @see android.os.Environment.DIRECTORY_PICTURES, 683 | * @see android.os.Environment.DIRECTORY_MOVIES, 684 | * @see android.os.Environment.DIRECTORY_DOCUMENTS 685 | * @return uri 686 | */ 687 | fun Context.createUriInPublicDir(name: String, dir: String = Environment.DIRECTORY_DOCUMENTS): Uri? { 688 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 689 | val contentValues = ContentValues() //内容 690 | val resolver = contentResolver //内容解析器 691 | contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name) //文件名 692 | contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "*/*") //文件类型 693 | //存放picture目录 694 | contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, dir) 695 | resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) 696 | } else { 697 | val file = File(Environment.getExternalStoragePublicDirectory(dir), name) 698 | file.createNewFile() 699 | Uri.fromFile(file) 700 | } 701 | } 702 | 703 | 704 | /** 705 | * getFilesDir和getCacheDir是在手机自带的一块存储区域(internal storage),通常比较小,SD卡取出也不会影响到,App的sqlite数据库和SharedPreferences都存储在这里。所以这里应该存放特别私密重要的东西。 706 | * 707 | * getExternalFilesDir和getExternalCacheDir是在SD卡下(external storage),在sdcard/Android/data/包名/files和sdcard/Android/data/包名/cache下,会跟随App卸载被删除。 708 | * 709 | * @param type The type of files directory to return. May be {@code null} 710 | * for the root of the files directory or one of the following 711 | * constants for a subdirectory 712 | * @see android.os.Environment.DIRECTORY_MUSIC, 713 | * @see android.os.Environment.DIRECTORY_PODCASTS, 714 | * @see android.os.Environment.DIRECTORY_RINGTONES, 715 | * @see android.os.Environment.DIRECTORY_ALARMS, 716 | * @see android.os.Environment.DIRECTORY_NOTIFICATIONS, 717 | * @see android.os.Environment.DIRECTORY_PICTURES, 718 | * @see android.os.Environment.DIRECTORY_MOVIES 719 | */ 720 | fun Context.getExternalOrFilesDir(type: String?): File { 721 | // 如果获取为空则改为getFilesDir 722 | val dir = getExternalFilesDir(type) ?: filesDir 723 | if (!dir.exists()) { 724 | dir.mkdirs() 725 | } 726 | return dir 727 | } 728 | 729 | /** 730 | * getExternalOrFilesDir().getAbsolutePath() 731 | * @see getExternalOrFilesDir 732 | */ 733 | fun Context.getExternalOrFilesDirPath(type: String?): String { 734 | return getExternalOrFilesDir(type).absolutePath 735 | } 736 | 737 | /** 738 | * getFilesDir和getCacheDir是在手机自带的一块存储区域(internal storage),通常比较小,SD卡取出也不会影响到,App的sqlite数据库和SharedPreferences都存储在这里。所以这里应该存放特别私密重要的东西。 739 | * 740 | * getExternalFilesDir和getExternalCacheDir是在SD卡下(external storage),在sdcard/Android/data/包名/files和sdcard/Android/data/包名/cache下,会跟随App卸载被删除。 741 | */ 742 | fun Context.getExternalOrCacheDir(): File { 743 | // 如果获取为空则改为getCacheDir 744 | val dir = externalCacheDir ?: cacheDir 745 | if (!dir.exists()) { 746 | dir.mkdirs() 747 | } 748 | return dir 749 | } 750 | 751 | fun Context.getExternalOrCacheDirPath(): String { 752 | return getExternalOrCacheDir().absolutePath 753 | } 754 | 755 | 756 | /** 757 | * 在缓存目录下新键子目录 758 | */ 759 | fun Context.getCacheChildDir(child: String?): File { 760 | val name = if (TextUtils.isEmpty(child)) { 761 | "app" 762 | } else { 763 | child 764 | } 765 | val file = File(getExternalOrCacheDir(), name) 766 | file.mkdirs() 767 | return file 768 | } 769 | 770 | /** 771 | * 是否是当前作用域内的文件 772 | */ 773 | fun String?.isScopeFile(): Boolean { 774 | QFHelper.assertNotInit() 775 | this ?: return false 776 | //内部存储 777 | val filesDirString = QFHelper.context.filesDir.parent 778 | qFilelog("isScopeFile: file=$this,filesDirString=$filesDirString") 779 | if (!filesDirString.isNullOrEmpty() && this.contains(filesDirString)) { 780 | return File(this).exists() 781 | } 782 | //外部存储 783 | val externalFilesDirString = QFHelper.context.getExternalFilesDir(null)?.parent 784 | qFilelog("isScopeFile: file=$this,externalFilesDirString=$externalFilesDirString") 785 | if (!externalFilesDirString.isNullOrEmpty() && this.contains(externalFilesDirString)) { 786 | return File(this).exists() 787 | } 788 | return false 789 | } 790 | 791 | fun File?.isScopeFile(): Boolean { 792 | this ?: return false 793 | return this.absolutePath.isScopeFile() 794 | } 795 | 796 | /** 797 | * 是否是以下作用域父文件夹内的文件,如华为手机: 798 | * 手机内部存储:/data/user/0/ 799 | * 手机外部存储:/storage/emulated/0/Android/data/ 800 | * ps:不同手机可能不一致,主要是看filesDir,getExternalFilesDir的返回结果 801 | */ 802 | fun String?.isAndroidDataFile(): Boolean { 803 | QFHelper.assertNotInit() 804 | this ?: return false 805 | //内部存储 806 | val filesDirString = QFHelper.context.filesDir.parent 807 | //qFilelog( "isAndroidDataFile: file=$this,filesDirString=$filesDirString") 808 | if (!filesDirString.isNullOrEmpty()) { 809 | val dir = File(filesDirString).parent 810 | if (!dir.isNullOrEmpty() && this.contains(dir)) { 811 | return File(this).exists() 812 | } 813 | } 814 | //外部存储 815 | val externalFilesDirString = QFHelper.context.getExternalFilesDir(null)?.parent 816 | //qFilelog( "isAndroidDataFile: file=$this,externalFilesDirString=$externalFilesDirString") 817 | if (!externalFilesDirString.isNullOrEmpty()) { 818 | val dir = File(externalFilesDirString).parent 819 | if (!dir.isNullOrEmpty() && this.contains(dir)) { 820 | return File(this).exists() 821 | } 822 | } 823 | return false 824 | } 825 | 826 | fun File?.isAndroidDataFile(): Boolean { 827 | this ?: return false 828 | return this.absolutePath.isAndroidDataFile() 829 | } 830 | 831 | /** 832 | * 判断一个文件是否存在&&可读 833 | */ 834 | fun File?.isFileExists(): Boolean { 835 | this ?: return false 836 | val eacces = exists() && canRead() 837 | qFilelog("isFileExists: $absolutePath=$eacces") 838 | return eacces 839 | } 840 | 841 | /** 842 | * 判断Uri是否存在 843 | */ 844 | fun Uri?.isFileExists(): Boolean { 845 | QFHelper.assertNotInit() 846 | if (this == null) return false 847 | qFilelog("isFileExists: $this") 848 | var afd: AssetFileDescriptor? = null 849 | return try { 850 | afd = QFHelper.context.contentResolver.openAssetFileDescriptor(this, "r") 851 | afd != null 852 | } catch (e: FileNotFoundException) { 853 | false 854 | } finally { 855 | afd?.close() 856 | } 857 | } 858 | 859 | /** 860 | * Uri是否在内部存储设备中 861 | */ 862 | fun Uri.isExternalStorageDocument() = "com.android.externalstorage.documents" == this.authority 863 | 864 | /** 865 | * Uri是否在下载内容中 866 | */ 867 | fun Uri.isDownloadsDocument() = "com.android.providers.downloads.documents" == this.authority 868 | 869 | /** 870 | * Uri是否在多媒体中 871 | */ 872 | fun Uri.isMediaDocument() = "com.android.providers.media.documents" == this.authority 873 | 874 | /** 875 | * 判断uri是否是文件夹 876 | */ 877 | fun Uri.isDirectory(): Boolean { 878 | val paths: List = pathSegments 879 | return paths.size >= 2 && "tree" == paths[0] 880 | 881 | } 882 | 883 | /** 884 | * 根据Uri获取文件名 885 | */ 886 | fun Uri.getFileName(): String? { 887 | QFHelper.assertNotInit() 888 | val documentFile = DocumentFile.fromSingleUri(QFHelper.context, this) 889 | return documentFile?.name 890 | } 891 | 892 | /** 893 | * 根据Uri获取MimeType 894 | */ 895 | fun Uri.getMimeTypeByUri(): String? { 896 | QFHelper.assertNotInit() 897 | return QFHelper.context.contentResolver.getType(this) 898 | } 899 | 900 | 901 | /** 902 | * 根据MimeType获取拓展名 903 | */ 904 | fun String.getExtensionByMimeType(): String { 905 | var ext = "" 906 | runCatching { 907 | ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(this) ?: "" 908 | }.onFailure { 909 | it.printStackTrace() 910 | } 911 | return ext 912 | } 913 | 914 | /** 915 | * 根据Uri获取扩展名 916 | */ 917 | fun Uri.getExtensionByUri() = 918 | this.getMimeTypeByUri()?.getExtensionByMimeType() 919 | 920 | /** 921 | * 根据文件名获取扩展名 922 | */ 923 | fun String.getExtensionByFileName() = 924 | this.getMimeTypeByFileName().getExtensionByMimeType() 925 | 926 | /** 927 | * 根据文件名获取MimeType 928 | */ 929 | fun String.getMimeTypeByFileName(): String { 930 | var mimeType = "" 931 | runCatching { 932 | mimeType = URLConnection.getFileNameMap().getContentTypeFor(this) 933 | }.onFailure { 934 | it.printStackTrace() 935 | } 936 | return mimeType 937 | } 938 | 939 | /** 940 | * 复制文件 941 | */ 942 | fun File?.copyFile(dest: File) { 943 | this ?: return 944 | var input: InputStream? = null 945 | var output: OutputStream? = null 946 | try { 947 | if (!dest.exists()) { 948 | dest.createNewFile() 949 | } 950 | input = FileInputStream(this) 951 | output = FileOutputStream(dest) 952 | val buf = ByteArray(1024) 953 | var bytesRead: Int 954 | while (input.read(buf).also { bytesRead = it } > 0) { 955 | output.write(buf, 0, bytesRead) 956 | } 957 | output.flush() 958 | qFilelog("copyFile succeed: ${dest.absolutePath}") 959 | } catch (e: Exception) { 960 | qFilelog("copyFile error: " + e.message) 961 | e.printStackTrace() 962 | } finally { 963 | try { 964 | input?.close() 965 | output?.close() 966 | } catch (e: IOException) { 967 | e.printStackTrace() 968 | } 969 | } 970 | } 971 | 972 | 973 | fun qFilelog(msg: String?) { 974 | if (QFHelper.isLog) { 975 | Log.i("QFile", "$msg") 976 | } 977 | } 978 | -------------------------------------------------------------------------------- /solution/src/main/res/drawable-hdpi/ic_qf_camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-hdpi/ic_qf_camera.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-hdpi/ic_qf_checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-hdpi/ic_qf_checked.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-hdpi/ic_qf_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-hdpi/ic_qf_img.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-xhdpi/ic_qf_camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-xhdpi/ic_qf_camera.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-xhdpi/ic_qf_checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-xhdpi/ic_qf_checked.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-xhdpi/ic_qf_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-xhdpi/ic_qf_img.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-xxhdpi/ic_qf_camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-xxhdpi/ic_qf_camera.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-xxhdpi/ic_qf_checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-xxhdpi/ic_qf_checked.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable-xxhdpi/ic_qf_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iDeMonnnnnn/QFsolution/da4e751a7dddd52c5371a361d0fc47ba19e28175/solution/src/main/res/drawable-xxhdpi/ic_qf_img.png -------------------------------------------------------------------------------- /solution/src/main/res/drawable/qf_btn_ok.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /solution/src/main/res/drawable/qf_unchecked.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /solution/src/main/res/layout/activity_qf_big_img.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /solution/src/main/res/layout/activity_qf_imgs.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 23 | 24 | 35 | 36 |