├── .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/#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 |
47 |
48 |
49 |
50 |
55 |
--------------------------------------------------------------------------------
/solution/src/main/res/layout/list_qf_img.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
15 |
16 |
24 |
--------------------------------------------------------------------------------
/solution/src/main/res/values-en/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Select Image
4 | (%1$d/%2$d)Confirm
5 | Confirm
6 | Please apply for camera permission first!
7 | Please select at least one picture!
8 | Cannot select more pictures!
9 | Please apply for storage permission first!
10 | maxNum no less than 1!
11 |
--------------------------------------------------------------------------------
/solution/src/main/res/values-ja/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 画像を選択
4 | 決定
5 | (%1d%2d)
6 | を確定してカメラ権限を先に申請してください!
7 | 少なくとも1枚の画像を選んでください!
8 | 画像をもっと選べない!
9 | 先にストレージ権限を申請してください!
10 | maxNumは1より小さくてはならない!
11 |
--------------------------------------------------------------------------------
/solution/src/main/res/values-ko/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 그림
4 | 확인
5 | (%1d%2d)확인
6 | 먼저 카메라 권한을 신청하세요!
7 | 적어도 하나의 그림을 선택하십시오!
8 | 더 많은 그림을 선택할 수 없습니다!
9 | 먼저 저장 권한을 신청하세요!
10 | maxnum은 1보다 작으면 안 된다!
11 |
--------------------------------------------------------------------------------
/solution/src/main/res/values-zh-rTW/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 選擇圖片
4 | 確定
5 | (%1$d/%2$d)確定
6 | 請先申請相機權限!
7 | 請選擇至少一張圖片!
8 | 無法選擇更多圖片!
9 | 請先申請存儲權限!
10 | maxNum不能小于1!
11 |
--------------------------------------------------------------------------------
/solution/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #000000
4 | #ff212121
5 | #44000000
6 | #4CAF50
7 |
8 |
9 | #999999
10 | #333333
11 |
12 | #ffffff
13 | #fafafa
14 | #eaeaea
15 |
16 | #000000
17 | #1b1b1b
18 | #26000000
19 | #33000000
20 | #3c000000
21 |
22 |
--------------------------------------------------------------------------------
/solution/src/main/res/values/dimen.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 2dp
4 |
--------------------------------------------------------------------------------
/solution/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 选择图片
4 | 确定
5 | (%1$d/%2$d)确定
6 | 请先申请相机权限!
7 | 请选择至少一张图片!
8 | 无法选择更多图片!
9 | 请先申请存储权限!
10 | maxNum不能小于1!
11 |
--------------------------------------------------------------------------------
/solution/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
18 |
19 |
--------------------------------------------------------------------------------