├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values-night
│ │ │ │ ├── colors.xml
│ │ │ │ └── themes.xml
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── xml
│ │ │ │ └── file_provider_paths.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── values
│ │ │ │ ├── themes.xml
│ │ │ │ ├── strings.xml
│ │ │ │ └── colors.xml
│ │ │ └── drawable
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── github
│ │ │ │ └── leavesczy
│ │ │ │ └── matisse
│ │ │ │ └── samples
│ │ │ │ ├── theme
│ │ │ │ └── Theme.kt
│ │ │ │ ├── MatisseApplication.kt
│ │ │ │ ├── logic
│ │ │ │ ├── Models.kt
│ │ │ │ └── MainViewModel.kt
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── github
│ │ │ └── leavesczy
│ │ │ └── matisse
│ │ │ └── samples
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── github
│ │ └── leavesczy
│ │ └── matisse
│ │ └── samples
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── matisse
├── .gitignore
├── consumer-rules.pro
├── src
│ └── main
│ │ ├── res
│ │ ├── anim
│ │ │ ├── animation_matisse_activity_static.xml
│ │ │ ├── animation_matisse_activity_from_center_to_right.xml
│ │ │ └── animation_matisse_activity_from_right_to_center.xml
│ │ ├── layout
│ │ │ └── activity_matisse_video_view.xml
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ ├── themes.xml
│ │ │ └── colors.xml
│ │ └── values-night
│ │ │ └── colors.xml
│ │ ├── java
│ │ └── github
│ │ │ └── leavesczy
│ │ │ └── matisse
│ │ │ ├── ImageEngine.kt
│ │ │ ├── internal
│ │ │ ├── ui
│ │ │ │ ├── MatisseTheme.kt
│ │ │ │ ├── MatisseClickable.kt
│ │ │ │ ├── MatisseLoadingDialog.kt
│ │ │ │ ├── MatisseCheckbox.kt
│ │ │ │ ├── MatisseBottomBar.kt
│ │ │ │ ├── MatissePreviewPage.kt
│ │ │ │ ├── MatisseTopBar.kt
│ │ │ │ └── MatissePage.kt
│ │ │ ├── logic
│ │ │ │ ├── MatisseTakePictureContract.kt
│ │ │ │ ├── Models.kt
│ │ │ │ ├── MediaProvider.kt
│ │ │ │ └── MatisseViewModel.kt
│ │ │ ├── MatisseCaptureActivity.kt
│ │ │ ├── MatisseVideoViewActivity.kt
│ │ │ ├── BaseCaptureActivity.kt
│ │ │ └── MatisseActivity.kt
│ │ │ ├── MatisseCaptureContract.kt
│ │ │ ├── MatisseContract.kt
│ │ │ ├── MediaFilter.kt
│ │ │ ├── GlideImageEngine.kt
│ │ │ ├── CoilImageEngine.kt
│ │ │ ├── Matisse.kt
│ │ │ └── CaptureStrategy.kt
│ │ └── AndroidManifest.xml
├── proguard-rules.pro
└── build.gradle.kts
├── jitpack.yml
├── key.jks
├── .gitignore
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── settings.gradle.kts
├── README.md
├── gradle.properties
├── gradlew.bat
├── gradlew
└── LICENSE
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/matisse/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/matisse/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/jitpack.yml:
--------------------------------------------------------------------------------
1 | jdk:
2 | - openjdk21
--------------------------------------------------------------------------------
/key.jks:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/key.jks
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | local.properties
3 | .gradle
4 | .idea
5 | .kotlin
6 | /build
--------------------------------------------------------------------------------
/app/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leavesCZY/Matisse/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_provider_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
--------------------------------------------------------------------------------
/matisse/src/main/res/anim/animation_matisse_activity_static.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/matisse/src/main/res/anim/animation_matisse_activity_from_center_to_right.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/matisse/src/main/res/anim/animation_matisse_activity_from_right_to_center.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/test/java/github/leavesczy/matisse/samples/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | pluginManagement {
4 | includeBuild("build-logic")
5 | repositories {
6 | google()
7 | mavenCentral()
8 | gradlePluginPortal()
9 | }
10 | }
11 |
12 | dependencyResolutionManagement {
13 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
14 | repositories {
15 | google()
16 | mavenCentral()
17 | }
18 | }
19 |
20 | rootProject.name = "Matisse"
21 | include(":app")
22 | include(":matisse")
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/ImageEngine.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.os.Parcelable
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.Stable
6 |
7 | /**
8 | * @Author: leavesCZY
9 | * @Date: 2023/6/7 23:11
10 | * @Desc:
11 | */
12 | @Stable
13 | interface ImageEngine : Parcelable {
14 |
15 | /**
16 | * 加载缩略图时调用
17 | */
18 | @Composable
19 | fun Thumbnail(mediaResource: MediaResource)
20 |
21 | /**
22 | * 加载大图时调用
23 | */
24 | @Composable
25 | fun Image(mediaResource: MediaResource)
26 |
27 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
13 |
14 |
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatisseTheme.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.lightColorScheme
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 |
8 | /**
9 | * @Author: CZY
10 | * @Date: 2025/5/7 11:53
11 | * @Desc:
12 | */
13 | @Composable
14 | internal fun MatisseTheme(content: @Composable () -> Unit) {
15 | val lightColorScheme = remember {
16 | lightColorScheme()
17 | }
18 | MaterialTheme(
19 | colorScheme = lightColorScheme,
20 | content = content
21 | )
22 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
13 |
14 |
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatisseClickable.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.ui.Modifier
8 |
9 | /**
10 | * @Author: leavesCZY
11 | * @Date: 2023/2/19 18:56
12 | * @Desc:
13 | */
14 | @Composable
15 | internal fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier {
16 | return clickable(
17 | indication = null,
18 | interactionSource = remember { MutableInteractionSource() },
19 | onClick = onClick
20 | )
21 | }
--------------------------------------------------------------------------------
/matisse/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.kts.
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
--------------------------------------------------------------------------------
/app/src/androidTest/java/github/leavesczy/matisse/samples/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("github.leavesczy.matisse.samples", appContext.packageName)
21 | }
22 | }
--------------------------------------------------------------------------------
/app/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.kts.
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
22 |
23 | -optimizationpasses 10
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Matisse
3 | 所有
4 | 请授予相册访问权限后重试 ~
5 | 请授予存储写入权限后重试 ~
6 | 请授予拍照权限后重试 ~
7 | 最多只能选择 %d 张图片 ~
8 | 最多只能选择 %d 个视频 ~
9 | 最多只能选择 %d 个图片或视频 ~
10 | 没有可用于拍照的应用 ~
11 | 点击预览
12 | 确定(%d/%d)
13 | 返回
14 |
--------------------------------------------------------------------------------
/matisse/src/main/res/layout/activity_matisse_video_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
19 |
20 |
24 |
25 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.matisse.android.application)
3 | alias(libs.plugins.matisse.android.compose)
4 | }
5 |
6 | android {
7 | namespace = "github.leavesczy.matisse.samples"
8 | }
9 |
10 | dependencies {
11 | testImplementation(libs.junit)
12 | androidTestImplementation(libs.androidx.junit)
13 | androidTestImplementation(libs.androidx.espresso)
14 | implementation(libs.androidx.appcompat)
15 | implementation(libs.androidx.activity.compose)
16 | implementation(libs.androidx.lifecycle.runtime)
17 | implementation(platform(libs.androidx.compose.bom))
18 | implementation(libs.androidx.compose.ui)
19 | implementation(libs.androidx.compose.foundation)
20 | implementation(libs.androidx.compose.material3)
21 | implementation(libs.coil.compose)
22 | implementation(libs.coil.gif)
23 | implementation(libs.coil.video)
24 | implementation(libs.glide.compose)
25 | implementation(project(":matisse"))
26 | }
--------------------------------------------------------------------------------
/matisse/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 全部
4 | 请授予相册访问权限后重试
5 | 请授予存储写入权限后重试
6 | 请授予拍照权限后重试
7 | 最多只能选择 %d 张图片
8 | 最多只能选择 %d 个视频
9 | 最多只能选择 %d 个图片或视频
10 | 不能同时选择图片和视频
11 | 没有可用于拍照的应用
12 | 预览
13 | 确定(%d/%d)
14 | 返回
15 |
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/MatisseCaptureContract.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import androidx.activity.result.contract.ActivityResultContract
7 | import androidx.core.content.IntentCompat
8 | import github.leavesczy.matisse.internal.MatisseCaptureActivity
9 |
10 | /**
11 | * @Author: leavesCZY
12 | * @Date: 2023/4/11 16:38
13 | * @Desc:
14 | */
15 | class MatisseCaptureContract : ActivityResultContract() {
16 |
17 | override fun createIntent(context: Context, input: MatisseCapture): Intent {
18 | val intent = Intent(context, MatisseCaptureActivity::class.java)
19 | intent.putExtra(MatisseCapture::class.java.name, input)
20 | return intent
21 | }
22 |
23 | override fun parseResult(resultCode: Int, intent: Intent?): MediaResource? {
24 | return if (resultCode == Activity.RESULT_OK && intent != null) {
25 | IntentCompat.getParcelableExtra(
26 | intent,
27 | MediaResource::class.java.name,
28 | MediaResource::class.java
29 | )
30 | } else {
31 | null
32 | }
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/github/leavesczy/matisse/samples/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.material3.darkColorScheme
6 | import androidx.compose.material3.lightColorScheme
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.graphics.Color
9 |
10 | private val lightColorScheme = lightColorScheme(
11 | background = Color(0xFFFFFFFF),
12 | primary = Color(0xFF03A9F4),
13 | onPrimary = Color(0xFFFFFFFF),
14 | secondary = Color(0xFF625b71),
15 | tertiary = Color(0xFF7D5260)
16 | )
17 |
18 | private val darkColorScheme = darkColorScheme(
19 | background = Color(0xFF101010),
20 | primary = Color(0xFF09A293),
21 | onPrimary = Color(0xFFFFFFFF),
22 | secondary = Color(0xFFCCC2DC),
23 | tertiary = Color(0xFFEFB8C8)
24 | )
25 |
26 | @Composable
27 | fun MatisseTheme(
28 | darkTheme: Boolean = isSystemInDarkTheme(),
29 | content: @Composable () -> Unit
30 | ) {
31 | val colorScheme = if (darkTheme) {
32 | darkColorScheme
33 | } else {
34 | lightColorScheme
35 | }
36 | MaterialTheme(
37 | colorScheme = colorScheme,
38 | content = content
39 | )
40 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/logic/MatisseTakePictureContract.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.logic
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.net.Uri
7 | import android.os.Bundle
8 | import android.provider.MediaStore
9 | import androidx.activity.result.contract.ActivityResultContract
10 |
11 | /**
12 | * @Author: leavesCZY
13 | * @Date: 2023/6/28 17:40
14 | * @Desc:
15 | */
16 | internal class MatisseTakePictureContract :
17 | ActivityResultContract() {
18 |
19 | data class MatisseTakePictureContractParams(
20 | val uri: Uri,
21 | val extra: Bundle
22 | )
23 |
24 | override fun createIntent(context: Context, input: MatisseTakePictureContractParams): Intent {
25 | val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
26 | val extra = input.extra
27 | if (!extra.isEmpty) {
28 | intent.putExtras(extra)
29 | }
30 | intent.putExtra(MediaStore.EXTRA_OUTPUT, input.uri)
31 | return intent
32 | }
33 |
34 | override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
35 | return resultCode == Activity.RESULT_OK
36 | }
37 |
38 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Matisse
2 |
3 | 一个用 Jetpack Compose 实现的 Android 图片视频选择框架
4 |
5 | - 解决了多个系统兼容性问题
6 | - 按需索取权限,遵循最佳用户实践
7 | - 完全用 Kotlin & Jetpack Compose 实现
8 | - 支持多种拍照策略,可以自由定义拍照逻辑
9 | - 支持自定义图片加载框架,可以自由定义加载逻辑
10 | - 支持同时选择图片和视频,或者单独选择两者之一
11 | - 支持精细自定义主题,提供了日夜间两套默认主题
12 |
13 | 关联的文章:
14 |
15 | - [Jetpack Compose 实现一个图片选择框架](https://juejin.cn/post/7108420791502372895)
16 | - [Android 13 媒体权限适配指南](https://juejin.cn/post/7159999910748618766)
17 |
18 | 接入指南:[Wiki](https://github.com/leavesCZY/Matisse/wiki)
19 |
20 | | 日间主题 | 夜间主题 | 自定义主题 |
21 | |:----------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------:|
22 | |  |  |  |
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/MatisseContract.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import androidx.activity.result.contract.ActivityResultContract
7 | import androidx.core.content.IntentCompat
8 | import github.leavesczy.matisse.internal.MatisseActivity
9 |
10 | /**
11 | * @Author: leavesCZY
12 | * @Date: 2022/6/2 15:30
13 | * @Desc:
14 | */
15 | class MatisseContract : ActivityResultContract?>() {
16 |
17 | override fun createIntent(context: Context, input: Matisse): Intent {
18 | val intent = Intent(context, MatisseActivity::class.java)
19 | intent.putExtra(Matisse::class.java.name, input)
20 | return intent
21 | }
22 |
23 | override fun parseResult(resultCode: Int, intent: Intent?): List? {
24 | val result = if (resultCode == Activity.RESULT_OK && intent != null) {
25 | IntentCompat.getParcelableArrayListExtra(
26 | intent,
27 | MediaResource::class.java.name,
28 | MediaResource::class.java
29 | )
30 | } else {
31 | null
32 | }
33 | return if (result.isNullOrEmpty()) {
34 | null
35 | } else {
36 | result
37 | }
38 | }
39 |
40 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/MatisseCaptureActivity.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal
2 |
3 | import android.content.Intent
4 | import android.os.Bundle
5 | import androidx.core.content.IntentCompat
6 | import github.leavesczy.matisse.CaptureStrategy
7 | import github.leavesczy.matisse.MatisseCapture
8 | import github.leavesczy.matisse.MediaResource
9 |
10 | /**
11 | * @Author: leavesCZY
12 | * @Date: 2023/4/11 16:31
13 | * @Desc:
14 | */
15 | internal class MatisseCaptureActivity : BaseCaptureActivity() {
16 |
17 | private val matisseCapture by lazy(mode = LazyThreadSafetyMode.NONE) {
18 | IntentCompat.getParcelableExtra(
19 | intent,
20 | MatisseCapture::class.java.name,
21 | MatisseCapture::class.java
22 | )!!
23 | }
24 |
25 | override val captureStrategy: CaptureStrategy
26 | get() = matisseCapture.captureStrategy
27 |
28 | override fun onCreate(savedInstanceState: Bundle?) {
29 | super.onCreate(savedInstanceState)
30 | requestTakePicture()
31 | }
32 |
33 | override fun dispatchTakePictureResult(mediaResource: MediaResource) {
34 | val intent = Intent()
35 | intent.putExtra(MediaResource::class.java.name, mediaResource)
36 | setResult(RESULT_OK, intent)
37 | finish()
38 | }
39 |
40 | override fun takePictureCancelled() {
41 | setResult(RESULT_CANCELED)
42 | finish()
43 | }
44 |
45 | }
--------------------------------------------------------------------------------
/matisse/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
21 |
27 |
35 |
36 |
--------------------------------------------------------------------------------
/app/src/main/java/github/leavesczy/matisse/samples/MatisseApplication.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples
2 |
3 | import android.app.Application
4 | import android.os.Build
5 | import androidx.appcompat.app.AppCompatDelegate
6 | import coil3.ImageLoader
7 | import coil3.SingletonImageLoader
8 | import coil3.gif.AnimatedImageDecoder
9 | import coil3.gif.GifDecoder
10 | import coil3.request.allowHardware
11 | import coil3.request.crossfade
12 | import coil3.video.VideoFrameDecoder
13 |
14 | /**
15 | * @Author: leavesCZY
16 | * @Date: 2022/5/29 21:10
17 | * @Desc:
18 | */
19 | class MatisseApplication : Application() {
20 |
21 | override fun onCreate() {
22 | super.onCreate()
23 | AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
24 | initCoil()
25 | }
26 |
27 | private fun initCoil() {
28 | SingletonImageLoader.setSafe(factory = { context ->
29 | ImageLoader
30 | .Builder(context = context)
31 | .crossfade(enable = false)
32 | .allowHardware(enable = true)
33 | .components {
34 | if (Build.VERSION.SDK_INT >= 28) {
35 | add(AnimatedImageDecoder.Factory())
36 | } else {
37 | add(GifDecoder.Factory())
38 | }
39 | add(VideoFrameDecoder.Factory())
40 | }
41 | .build()
42 | })
43 | }
44 |
45 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/MediaFilter.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.net.Uri
4 | import android.os.Parcelable
5 | import kotlinx.parcelize.Parcelize
6 |
7 | /**
8 | * @Author: leavesCZY
9 | * @Date: 2023/8/21 18:13
10 | * @Desc:
11 | */
12 | interface MediaFilter : Parcelable {
13 |
14 | /**
15 | * 用于控制是否要忽略特定的媒体资源
16 | * 返回 true 则会被忽略,不会展示给用户
17 | */
18 | suspend fun ignoreMedia(mediaResource: MediaResource): Boolean
19 |
20 | /**
21 | * 用于控制是否要默认选中特定的媒体资源
22 | * 返回 true 则会被默认选中
23 | */
24 | suspend fun selectMedia(mediaResource: MediaResource): Boolean
25 |
26 | }
27 |
28 | /**
29 | * @param ignoredMimeType 包含在内的 mimeType 将会被忽略,不会展示给用户
30 | * @param ignoredResourceUri 包含在内的 Uri 将会被忽略,不会展示给用户
31 | * @param selectedResourceUri 包含在内的 Uri 将会被默认选中
32 | */
33 | @Parcelize
34 | class DefaultMediaFilter(
35 | private val ignoredMimeType: Set = emptySet(),
36 | private val ignoredResourceUri: Set = emptySet(),
37 | private val selectedResourceUri: Set = emptySet()
38 | ) : MediaFilter {
39 |
40 | override suspend fun ignoreMedia(mediaResource: MediaResource): Boolean {
41 | return ignoredMimeType.contains(element = mediaResource.mimeType) ||
42 | ignoredResourceUri.contains(element = mediaResource.uri)
43 | }
44 |
45 | override suspend fun selectMedia(mediaResource: MediaResource): Boolean {
46 | return selectedResourceUri.contains(element = mediaResource.uri)
47 | }
48 |
49 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatisseLoadingDialog.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.material3.CircularProgressIndicator
7 | import androidx.compose.material3.ProgressIndicatorDefaults
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.res.colorResource
13 | import androidx.compose.ui.unit.dp
14 | import github.leavesczy.matisse.R
15 |
16 | /**
17 | * @Author: leavesCZY
18 | * @Desc:
19 | */
20 | @Composable
21 | internal fun MatisseLoadingDialog(
22 | modifier: Modifier,
23 | visible: Boolean
24 | ) {
25 | if (visible) {
26 | Box(
27 | modifier = modifier
28 | .fillMaxSize()
29 | .clickableNoRipple {},
30 | contentAlignment = Alignment.Center
31 | ) {
32 | CircularProgressIndicator(
33 | modifier = Modifier
34 | .size(size = 42.dp),
35 | strokeWidth = 3.dp,
36 | color = colorResource(id = R.color.matisse_circular_loading_color),
37 | trackColor = Color.Transparent,
38 | strokeCap = ProgressIndicatorDefaults.CircularIndeterminateStrokeCap
39 | )
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/main/java/github/leavesczy/matisse/samples/logic/Models.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples.logic
2 |
3 | import androidx.compose.runtime.Stable
4 | import github.leavesczy.matisse.MediaResource
5 |
6 | /**
7 | * @Author: leavesCZY
8 | * @Date: 2024/2/21 12:01
9 | * @Desc:
10 | */
11 | @Stable
12 | data class MainPageViewState(
13 | val gridColumns: Int,
14 | val maxSelectable: Int,
15 | val fastSelect: Boolean,
16 | val singleMediaType: Boolean,
17 | val imageEngine: MediaImageEngine,
18 | val filterStrategy: MediaFilterStrategy,
19 | val captureStrategy: MediaCaptureStrategy,
20 | val capturePreferencesCustom: Boolean,
21 | val mediaList: List,
22 | val onGridColumnsChanged: (Int) -> Unit,
23 | val onMaxSelectableChanged: (Int) -> Unit,
24 | val onFastSelectChanged: (Boolean) -> Unit,
25 | val onSingleMediaTypeChanged: (Boolean) -> Unit,
26 | val onImageEngineChanged: (MediaImageEngine) -> Unit,
27 | val onFilterStrategyChanged: (MediaFilterStrategy) -> Unit,
28 | val onCaptureStrategyChanged: (MediaCaptureStrategy) -> Unit,
29 | val onCapturePreferencesCustomChanged: (Boolean) -> Unit,
30 | val switchTheme: () -> Unit
31 | )
32 |
33 | @Stable
34 | enum class MediaCaptureStrategy {
35 | Smart,
36 | FileProvider,
37 | MediaStore,
38 | Close
39 | }
40 |
41 | @Stable
42 | enum class MediaImageEngine {
43 | Coil,
44 | Glide
45 | }
46 |
47 | @Stable
48 | enum class MediaFilterStrategy {
49 | Nothing,
50 | IgnoreSelected,
51 | AttachSelected
52 | }
--------------------------------------------------------------------------------
/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=-Xmx4096m -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 | # Kotlin code style for this project: "official" or "obsolete":
18 | kotlin.code.style=official
19 | # Enables namespacing of each library's R class so that its R class includes only the
20 | # resources declared in the library itself and none from the library's dependencies,
21 | # thereby reducing the size of the R class for that library
22 | android.useAndroidX=true
23 | android.nonTransitiveRClass=true
24 | android.nonFinalResIds=true
25 | android.enableAppCompileTimeRClass=true
26 | android.enableR8.fullMode=true
27 | android.r8.strictFullModeForKeepRules=false
28 | android.r8.optimizedResourceShrinking=true
29 | android.defaults.buildfeatures.resvalues=false
30 | android.defaults.buildfeatures.shaders=false
31 | android.sdk.defaultTargetSdkToCompileSdkIfUnset=true
32 | android.uniquePackageNames=true
33 | android.usesSdkInManifest.disallowed=true
34 | android.dependency.useConstraints=true
35 | android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
36 | android.builtInKotlin=true
37 | android.newDsl=true
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
10 |
13 |
14 |
23 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
40 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/matisse/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
19 |
20 |
29 |
30 |
42 |
43 |
--------------------------------------------------------------------------------
/matisse/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | true
4 | true
5 | #FFFFFFFF
6 | #FFFFFFFF
7 | #FFFFFFFF
8 | #66CCCCCC
9 | #0F000000
10 | #80000000
11 | #66CCCCCC
12 | #FFFFFFFF
13 | #FFFFFFFF
14 | #FF000000
15 | #FF000000
16 | #FFFFFFFF
17 | #FF000000
18 | #FFFFFFFF
19 | #FF000000
20 | #FFC6CCD2
21 | #FF000000
22 | #FFC6CCD2
23 | #FF22202A
24 | #FF2B2A34
25 | #FFFFFFFF
26 | #FFFFFFFF
27 | #80FFFFFF
28 | #FFFFFFFF
29 | #80FFFFFF
30 | #FF03A9F4
31 | #FFFFFFFF
32 | #FF03A9F4
33 | #FFFFFFFF
34 | #FF22202A
35 |
--------------------------------------------------------------------------------
/matisse/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | false
4 | false
5 | #FF2B2A34
6 | #FF2B2A34
7 | #FF22202A
8 | #CCFFFFFF
9 | #0F000000
10 | #80000000
11 | #CCFFFFFF
12 | #FFFFFFFF
13 | #FF2B2A34
14 | #FFFFFFFF
15 | #FFFFFFFF
16 | #FF2B2A34
17 | #FFFFFFFF
18 | #FF2B2A34
19 | #FFFFFFFF
20 | #99FFFFFF
21 | #FFFFFFFF
22 | #99FFFFFF
23 | #FF22202A
24 | #FF2B2A34
25 | #FFFFFFFF
26 | #FFFFFFFF
27 | #99FFFFFF
28 | #FFFFFFFF
29 | #80FFFFFF
30 | #FF009688
31 | #FFFFFFFF
32 | #FF009688
33 | #FFFFFFFF
34 | #FF22202A
35 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF0691F6
4 | false
5 | true
6 | #FF03A9F4
7 | #FFFFFFFF
8 | #FFFFFFFF
9 | #66CCCCCC
10 | #0F000000
11 | #80000000
12 | #66CCCCCC
13 | #FFFFFFFF
14 | #FF03A9F4
15 | #FFFFFFFF
16 | #FFFFFFFF
17 | #FFFFFFFF
18 | #FF000000
19 | #FFFFFFFF
20 | #FF000000
21 | #FFC6CCD2
22 | #FF03A9F4
23 | #6003A9F4
24 | #FF22202A
25 | #FF2B2A34
26 | #FFFFFFFF
27 | #FF03A9F4
28 | #6003A9F4
29 | #FFFFFFFF
30 | #60FFFFFF
31 | #FF03A9F4
32 | #FFFFFFFF
33 | #FF03A9F4
34 | #FFFFFFFF
35 | #FF22202A
36 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | android-plugin = "9.0.0-rc01"
3 | kotlin-plugin = "2.3.0"
4 | maven-publish-plugin = "0.35.0"
5 |
6 | androidx-junit = "1.3.0"
7 | androidx-espresso = "3.7.0"
8 | androidx-appcompat = "1.7.1"
9 | androidx-activity = "1.12.2"
10 | androidx-lifecycle = "2.10.0"
11 | androidx-compose-bom = "2025.12.01"
12 |
13 | junit = "4.13.2"
14 |
15 | coil = "3.3.0"
16 | glide = "1.0.0-beta08"
17 |
18 | [plugins]
19 | android-application = { id = "com.android.application", version.ref = "android-plugin" }
20 | android-library = { id = "com.android.library", version.ref = "android-plugin" }
21 |
22 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-plugin" }
23 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin-plugin" }
24 |
25 | maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish-plugin" }
26 |
27 | matisse-android-application = { id = "matisse.android.application" }
28 | matisse-android-library = { id = "matisse.android.library" }
29 | matisse-android-compose = { id = "matisse.android.compose" }
30 |
31 | [libraries]
32 | android-gradle = { module = "com.android.tools.build:gradle", version.ref = "android-plugin" }
33 | kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-plugin" }
34 |
35 | androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" }
36 | androidx-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
37 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
38 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
39 | androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
40 |
41 | androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
42 | androidx-compose-ui = { module = "androidx.compose.ui:ui" }
43 | androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
44 | androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
45 | androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
46 |
47 | junit = { module = "junit:junit", version.ref = "junit" }
48 |
49 | coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
50 | coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil" }
51 | coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
52 |
53 | glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glide" }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/MatisseVideoViewActivity.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal
2 |
3 | import android.media.MediaPlayer
4 | import android.os.Bundle
5 | import android.widget.MediaController
6 | import android.widget.VideoView
7 | import androidx.activity.OnBackPressedCallback
8 | import androidx.appcompat.app.AppCompatActivity
9 | import androidx.core.content.IntentCompat
10 | import androidx.core.view.WindowCompat
11 | import github.leavesczy.matisse.MediaResource
12 | import github.leavesczy.matisse.R
13 |
14 | /**
15 | * @Author: leavesCZY
16 | * @Date: 2024/2/18 15:37
17 | * @Desc:
18 | */
19 | internal class MatisseVideoViewActivity : AppCompatActivity() {
20 |
21 | private val mediaResource by lazy(mode = LazyThreadSafetyMode.NONE) {
22 | IntentCompat.getParcelableExtra(
23 | intent,
24 | MediaResource::class.java.name,
25 | MediaResource::class.java
26 | )!!
27 | }
28 |
29 | private val videoView by lazy(mode = LazyThreadSafetyMode.NONE) {
30 | findViewById(R.id.videoView)
31 | }
32 |
33 | private val mediaController by lazy(mode = LazyThreadSafetyMode.NONE) {
34 | MediaController(this)
35 | }
36 |
37 | private val onPreparedListener = MediaPlayer.OnPreparedListener {
38 | mediaController.setAnchorView(videoView)
39 | mediaController.setMediaPlayer(videoView)
40 | videoView.setMediaController(mediaController)
41 | }
42 |
43 | private var lastPosition = -1
44 |
45 | override fun onCreate(savedInstanceState: Bundle?) {
46 | WindowCompat.setDecorFitsSystemWindows(window, false)
47 | super.onCreate(savedInstanceState)
48 | setContentView(R.layout.activity_matisse_video_view)
49 | addOnBackPressedObserver()
50 | videoView.setOnPreparedListener(onPreparedListener)
51 | videoView.setVideoURI(mediaResource.uri)
52 | videoView.start()
53 | }
54 |
55 | override fun onResume() {
56 | super.onResume()
57 | if (lastPosition > 0) {
58 | videoView.seekTo(lastPosition)
59 | }
60 | lastPosition = -1
61 | }
62 |
63 | override fun onPause() {
64 | lastPosition = videoView.currentPosition
65 | super.onPause()
66 | }
67 |
68 | override fun onDestroy() {
69 | super.onDestroy()
70 | videoView.setOnPreparedListener(null)
71 | videoView.suspend()
72 | }
73 |
74 | private fun addOnBackPressedObserver() {
75 | onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
76 | override fun handleOnBackPressed() {
77 | finish()
78 | }
79 | })
80 | }
81 |
82 | }
--------------------------------------------------------------------------------
/matisse/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
2 |
3 | plugins {
4 | alias(libs.plugins.matisse.android.library)
5 | alias(libs.plugins.matisse.android.compose)
6 | alias(libs.plugins.maven.publish)
7 | id("maven-publish")
8 | id("signing")
9 | }
10 |
11 | val signingKeyId = properties["signing.keyId"]?.toString()
12 |
13 | android {
14 | namespace = "github.leavesczy.matisse"
15 | }
16 |
17 | dependencies {
18 | implementation(libs.androidx.appcompat)
19 | implementation(libs.androidx.activity.compose)
20 | implementation(platform(libs.androidx.compose.bom))
21 | implementation(libs.androidx.compose.ui)
22 | implementation(libs.androidx.compose.foundation)
23 | implementation(libs.androidx.compose.material.icons.extended)
24 | implementation(libs.androidx.compose.material3)
25 | compileOnly(libs.coil.compose)
26 | compileOnly(libs.glide.compose)
27 | }
28 |
29 | val matisseVersion = "2.2.3"
30 |
31 | if (signingKeyId == null) {
32 | publishing {
33 | publications {
34 | create("release") {
35 | afterEvaluate {
36 | from(components["release"])
37 | }
38 | }
39 | }
40 | }
41 | } else {
42 | mavenPublishing {
43 | publishToMavenCentral()
44 | signAllPublications()
45 | configure(platform = AndroidSingleVariantLibrary())
46 | coordinates(
47 | groupId = "io.github.leavesczy",
48 | artifactId = "matisse",
49 | version = matisseVersion
50 | )
51 | pom {
52 | name = "Matisse"
53 | description =
54 | "An Android Image and Video Selection Framework Implemented with Jetpack Compose"
55 | inceptionYear = "2025"
56 | url = "https://github.com/leavesCZY/Matisse"
57 | licenses {
58 | license {
59 | name = "The Apache License, Version 2.0"
60 | url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
61 | distribution = "https://www.apache.org/licenses/LICENSE-2.0.txt"
62 | }
63 | }
64 | developers {
65 | developer {
66 | id = "leavesCZY"
67 | name = "leavesCZY"
68 | url = "https://github.com/leavesCZY"
69 | }
70 | }
71 | scm {
72 | url = "https://github.com/leavesCZY/Matisse"
73 | connection = "scm:git:git://github.com/leavesCZY/Matisse.git"
74 | developerConnection = "scm:git:ssh://git@github.com/leavesCZY/Matisse.git"
75 | }
76 | }
77 | }
78 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/logic/Models.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.logic
2 |
3 | import androidx.compose.foundation.lazy.grid.LazyGridState
4 | import androidx.compose.runtime.Stable
5 | import androidx.compose.runtime.State
6 | import github.leavesczy.matisse.CaptureStrategy
7 | import github.leavesczy.matisse.ImageEngine
8 | import github.leavesczy.matisse.MediaResource
9 |
10 | /**
11 | * @Author: leavesCZY
12 | * @Date: 2022/5/30 23:24
13 | * @Desc:
14 | */
15 | @Stable
16 | internal data class MatissePageViewState(
17 | val maxSelectable: Int,
18 | val fastSelect: Boolean,
19 | val gridColumns: Int,
20 | val imageEngine: ImageEngine,
21 | val captureStrategy: CaptureStrategy?,
22 | val mediaBucketsInfo: List,
23 | val selectedBucket: MatisseMediaBucket,
24 | val lazyGridState: LazyGridState,
25 | val onClickBucket: suspend (String) -> Unit,
26 | val onClickMedia: (MatisseMediaExtend) -> Unit,
27 | val onMediaCheckChanged: (MatisseMediaExtend) -> Unit
28 | )
29 |
30 | @Stable
31 | internal data class MatisseMediaExtend(
32 | val mediaId: Long,
33 | val bucketId: String,
34 | val bucketName: String,
35 | val media: MediaResource,
36 | val selectState: State
37 | )
38 |
39 | @Stable
40 | internal data class MatisseMediaSelectState(
41 | val isSelected: Boolean,
42 | val isEnabled: Boolean,
43 | val positionIndex: Int
44 | ) {
45 |
46 | val positionFormatted = run {
47 | if (positionIndex >= 0) {
48 | (positionIndex + 1).toString()
49 | } else {
50 | null
51 | }
52 | }
53 |
54 | }
55 |
56 | @Stable
57 | internal data class MatisseMediaBucket(
58 | val bucketId: String,
59 | val bucketName: String,
60 | val supportCapture: Boolean,
61 | val resources: List
62 | )
63 |
64 | @Stable
65 | internal data class MatisseMediaBucketInfo(
66 | val bucketId: String,
67 | val bucketName: String,
68 | val size: Int,
69 | val firstMedia: MediaResource?
70 | )
71 |
72 | @Stable
73 | internal data class MatisseBottomBarViewState(
74 | val previewButtonText: String,
75 | val previewButtonClickable: Boolean,
76 | val onClickPreviewButton: () -> Unit,
77 | val sureButtonText: String,
78 | val sureButtonClickable: Boolean
79 | )
80 |
81 | @Stable
82 | internal data class MatissePreviewPageViewState(
83 | val visible: Boolean,
84 | val initialPage: Int,
85 | val maxSelectable: Int,
86 | val sureButtonText: String,
87 | val sureButtonClickable: Boolean,
88 | val previewResources: List,
89 | val onMediaCheckChanged: (MatisseMediaExtend) -> Unit,
90 | val onDismissRequest: () -> Unit
91 | )
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/GlideImageEngine.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.net.Uri
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.rememberScrollState
9 | import androidx.compose.foundation.verticalScroll
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Alignment
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.layout.ContentScale
15 | import androidx.compose.ui.res.colorResource
16 | import com.bumptech.glide.integration.compose.GlideImage
17 | import com.bumptech.glide.integration.compose.placeholder
18 | import kotlinx.parcelize.Parcelize
19 |
20 | /**
21 | * @Author: CZY
22 | * @Date: 2025/7/23 21:24
23 | * @Desc:
24 | */
25 | @Parcelize
26 | class GlideImageEngine : ImageEngine {
27 |
28 | @Composable
29 | override fun Thumbnail(mediaResource: MediaResource) {
30 | GlideComposeImage(
31 | modifier = Modifier
32 | .fillMaxSize(),
33 | model = mediaResource.uri,
34 | contentScale = ContentScale.Crop
35 | )
36 | }
37 |
38 | @Composable
39 | override fun Image(mediaResource: MediaResource) {
40 | if (mediaResource.isVideo) {
41 | GlideComposeImage(
42 | modifier = Modifier
43 | .fillMaxWidth(),
44 | model = mediaResource.uri,
45 | contentScale = ContentScale.FillWidth
46 | )
47 | } else {
48 | GlideComposeImage(
49 | modifier = Modifier
50 | .fillMaxWidth()
51 | .verticalScroll(state = rememberScrollState()),
52 | model = mediaResource.uri,
53 | contentScale = ContentScale.FillWidth
54 | )
55 | }
56 | }
57 |
58 | }
59 |
60 | @Composable
61 | private fun GlideComposeImage(
62 | modifier: Modifier,
63 | model: Uri,
64 | contentScale: ContentScale = ContentScale.Crop,
65 | alignment: Alignment = Alignment.Center,
66 | backgroundColor: Color? = colorResource(id = R.color.matisse_media_item_background_color)
67 | ) {
68 | GlideImage(
69 | modifier = modifier,
70 | model = model,
71 | contentScale = contentScale,
72 | alignment = alignment,
73 | loading = if (backgroundColor != null) {
74 | placeholder {
75 | Placeholder(backgroundColor = backgroundColor)
76 | }
77 | } else {
78 | null
79 | },
80 | failure = if (backgroundColor != null) {
81 | placeholder {
82 | Placeholder(backgroundColor = backgroundColor)
83 | }
84 | } else {
85 | null
86 | },
87 | contentDescription = null
88 | )
89 | }
90 |
91 | @Composable
92 | private fun Placeholder(backgroundColor: Color) {
93 | Box(
94 | modifier = Modifier
95 | .fillMaxSize()
96 | .background(color = backgroundColor)
97 | )
98 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/CoilImageEngine.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.rememberScrollState
8 | import androidx.compose.foundation.verticalScroll
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.collectAsState
11 | import androidx.compose.runtime.getValue
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.compose.ui.layout.ContentScale
16 | import androidx.compose.ui.res.colorResource
17 | import coil3.compose.AsyncImagePainter
18 | import coil3.compose.SubcomposeAsyncImage
19 | import coil3.compose.SubcomposeAsyncImageContent
20 | import kotlinx.parcelize.Parcelize
21 |
22 | /**
23 | * @Author: CZY
24 | * @Date: 2025/7/23 21:24
25 | * @Desc:
26 | */
27 | @Parcelize
28 | class CoilImageEngine : ImageEngine {
29 |
30 | @Composable
31 | override fun Thumbnail(mediaResource: MediaResource) {
32 | CoilComposeImage(
33 | modifier = Modifier
34 | .fillMaxSize(),
35 | model = mediaResource.uri,
36 | contentScale = ContentScale.Crop
37 | )
38 | }
39 |
40 | @Composable
41 | override fun Image(mediaResource: MediaResource) {
42 | if (mediaResource.isVideo) {
43 | CoilComposeImage(
44 | modifier = Modifier
45 | .fillMaxWidth(),
46 | model = mediaResource.uri,
47 | contentScale = ContentScale.FillWidth
48 | )
49 | } else {
50 | CoilComposeImage(
51 | modifier = Modifier
52 | .fillMaxWidth()
53 | .verticalScroll(state = rememberScrollState()),
54 | model = mediaResource.uri,
55 | contentScale = ContentScale.FillWidth
56 | )
57 | }
58 | }
59 |
60 | }
61 |
62 | @Composable
63 | private fun CoilComposeImage(
64 | modifier: Modifier,
65 | model: Any,
66 | alignment: Alignment = Alignment.Center,
67 | contentScale: ContentScale = ContentScale.Crop,
68 | backgroundColor: Color? = colorResource(id = R.color.matisse_media_item_background_color)
69 | ) {
70 | SubcomposeAsyncImage(
71 | modifier = modifier,
72 | model = model,
73 | alignment = alignment,
74 | contentScale = contentScale,
75 | contentDescription = null
76 | ) {
77 | val state by painter.state.collectAsState()
78 | when (state) {
79 | AsyncImagePainter.State.Empty,
80 | is AsyncImagePainter.State.Loading,
81 | is AsyncImagePainter.State.Error -> {
82 | if (backgroundColor != null) {
83 | Box(
84 | modifier = Modifier
85 | .fillMaxSize()
86 | .background(color = backgroundColor)
87 | )
88 | }
89 | }
90 |
91 | is AsyncImagePainter.State.Success -> {
92 | SubcomposeAsyncImageContent()
93 | }
94 | }
95 | }
96 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatisseCheckbox.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.wrapContentSize
9 | import androidx.compose.foundation.shape.CircleShape
10 | import androidx.compose.foundation.text.BasicText
11 | import androidx.compose.foundation.text.TextAutoSize
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.draw.clip
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.res.colorResource
18 | import androidx.compose.ui.text.TextStyle
19 | import androidx.compose.ui.text.font.FontStyle
20 | import androidx.compose.ui.text.font.FontWeight
21 | import androidx.compose.ui.text.style.TextAlign
22 | import androidx.compose.ui.text.style.TextOverflow
23 | import androidx.compose.ui.unit.dp
24 | import androidx.compose.ui.unit.sp
25 | import github.leavesczy.matisse.R
26 | import github.leavesczy.matisse.internal.logic.MatisseMediaSelectState
27 |
28 | /**
29 | * @Author: leavesCZY
30 | * @Date: 2022/5/31 14:27
31 | * @Desc:
32 | */
33 | @Composable
34 | internal fun MatisseCheckbox(
35 | modifier: Modifier,
36 | selectState: MatisseMediaSelectState,
37 | onClick: () -> Unit
38 | ) {
39 | Box(
40 | modifier = modifier
41 | .clickableNoRipple(onClick = onClick),
42 | contentAlignment = Alignment.Center
43 | ) {
44 | Spacer(
45 | modifier = Modifier
46 | .fillMaxSize()
47 | .clip(shape = CircleShape)
48 | .then(
49 | other = if (selectState.isSelected) {
50 | Modifier
51 | .background(color = colorResource(id = R.color.matisse_check_box_circle_fill_color))
52 | } else {
53 | Modifier
54 | .background(color = Color(0x1A000000))
55 | .border(
56 | width = 1.dp,
57 | shape = CircleShape,
58 | color = colorResource(
59 | id = if (selectState.isEnabled) {
60 | R.color.matisse_check_box_circle_color
61 | } else {
62 | R.color.matisse_check_box_circle_color_if_disable
63 | }
64 | )
65 | )
66 | }
67 | )
68 | )
69 | val positionFormatted = selectState.positionFormatted
70 | if (!positionFormatted.isNullOrBlank()) {
71 | BasicText(
72 | modifier = Modifier
73 | .matchParentSize()
74 | .wrapContentSize(align = Alignment.Center),
75 | text = positionFormatted,
76 | maxLines = 1,
77 | overflow = TextOverflow.Clip,
78 | autoSize = TextAutoSize.StepBased(
79 | minFontSize = 6.sp,
80 | maxFontSize = 18.sp,
81 | stepSize = 1.sp
82 | ),
83 | style = TextStyle(
84 | color = colorResource(id = R.color.matisse_check_box_text_color),
85 | fontStyle = FontStyle.Normal,
86 | fontWeight = FontWeight.Normal,
87 | textAlign = TextAlign.Center
88 | )
89 | )
90 | }
91 | }
92 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/Matisse.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.net.Uri
4 | import android.os.Parcelable
5 | import androidx.compose.runtime.Stable
6 | import kotlinx.parcelize.Parcelize
7 |
8 | /**
9 | * @Author: leavesCZY
10 | * @Date: 2022/6/1 17:45
11 | * @Desc:
12 | */
13 | /**
14 | * @param maxSelectable 最多能选择几个媒体资源
15 | * @param imageEngine 图片加载框架
16 | * @param gridColumns 一行要显示几个媒体资源。默认值为 4
17 | * @param fastSelect 是否要点击媒体资源后立即返回,值为 true 时 maxSelectable 必须为 1。默认不立即返回
18 | * @param mediaType 要加载的媒体资源类型。默认仅图片
19 | * @param singleMediaType 是否允许同时选择图片和视频。默认允许
20 | * @param mediaFilter 媒体资源的筛选规则。默认不进行筛选
21 | * @param captureStrategy 拍照策略。默认不开启拍照功能
22 | */
23 | @Stable
24 | @Parcelize
25 | data class Matisse(
26 | val maxSelectable: Int,
27 | val imageEngine: ImageEngine,
28 | val gridColumns: Int = 4,
29 | val fastSelect: Boolean = false,
30 | val mediaType: MediaType = MediaType.ImageOnly,
31 | val singleMediaType: Boolean = false,
32 | val mediaFilter: MediaFilter? = null,
33 | val captureStrategy: CaptureStrategy? = null
34 | ) : Parcelable {
35 |
36 | init {
37 | if (maxSelectable < 1) {
38 | throw IllegalArgumentException("maxSelectable should be larger than zero")
39 | }
40 | if (maxSelectable > 1 && fastSelect) {
41 | throw IllegalArgumentException("when maxSelectable is greater than 1, fastSelect must be false")
42 | }
43 | if (gridColumns < 1) {
44 | throw IllegalArgumentException("gridColumns should be larger than zero")
45 | }
46 | }
47 |
48 | }
49 |
50 | /**
51 | * @param captureStrategy 拍照策略
52 | */
53 | @Parcelize
54 | data class MatisseCapture(
55 | val captureStrategy: CaptureStrategy
56 | ) : Parcelable
57 |
58 | @Parcelize
59 | sealed interface MediaType : Parcelable {
60 |
61 | @Parcelize
62 | data object ImageOnly : MediaType
63 |
64 | @Parcelize
65 | data object VideoOnly : MediaType
66 |
67 | @Parcelize
68 | data object ImageAndVideo : MediaType
69 |
70 | @Parcelize
71 | data class MultipleMimeType(val mimeTypes: Set) : MediaType {
72 |
73 | init {
74 | if (mimeTypes.isEmpty()) {
75 | throw IllegalArgumentException("mimeTypes cannot be empty")
76 | }
77 | }
78 |
79 | }
80 |
81 | val includeImage: Boolean
82 | get() = when (this) {
83 | ImageOnly, ImageAndVideo -> {
84 | true
85 | }
86 |
87 | VideoOnly -> {
88 | false
89 | }
90 |
91 | is MultipleMimeType -> {
92 | mimeTypes.any {
93 | it.startsWith(prefix = ImageMimeTypePrefix)
94 | }
95 | }
96 | }
97 |
98 | val includeVideo: Boolean
99 | get() = when (this) {
100 | ImageOnly -> {
101 | false
102 | }
103 |
104 | VideoOnly, ImageAndVideo -> {
105 | true
106 | }
107 |
108 | is MultipleMimeType -> {
109 | mimeTypes.any {
110 | it.startsWith(prefix = VideoMimeTypePrefix)
111 | }
112 | }
113 | }
114 |
115 | }
116 |
117 | internal const val ImageMimeTypePrefix = "image/"
118 |
119 | internal const val VideoMimeTypePrefix = "video/"
120 |
121 | @Stable
122 | @Parcelize
123 | data class MediaResource(
124 | val uri: Uri,
125 | val path: String,
126 | val name: String,
127 | val mimeType: String,
128 | val size: Long
129 | ) : Parcelable {
130 |
131 | val isImage: Boolean
132 | get() = mimeType.startsWith(prefix = ImageMimeTypePrefix)
133 |
134 | val isVideo: Boolean
135 | get() = mimeType.startsWith(prefix = VideoMimeTypePrefix)
136 |
137 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatisseBottomBar.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.navigationBarsPadding
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.shape.CircleShape
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.clip
17 | import androidx.compose.ui.draw.shadow
18 | import androidx.compose.ui.res.colorResource
19 | import androidx.compose.ui.text.font.FontStyle
20 | import androidx.compose.ui.text.font.FontWeight
21 | import androidx.compose.ui.unit.dp
22 | import androidx.compose.ui.unit.sp
23 | import github.leavesczy.matisse.R
24 | import github.leavesczy.matisse.internal.logic.MatisseBottomBarViewState
25 |
26 | /**
27 | * @Author: leavesCZY
28 | * @Date: 2022/6/1 19:19
29 | * @Desc:
30 | */
31 | @Composable
32 | internal fun MatisseBottomBar(
33 | modifier: Modifier,
34 | viewState: MatisseBottomBarViewState,
35 | onClickSure: () -> Unit
36 | ) {
37 | Row(
38 | modifier = modifier
39 | .shadow(elevation = 4.dp)
40 | .background(color = colorResource(id = R.color.matisse_navigation_bar_color))
41 | .navigationBarsPadding()
42 | .fillMaxWidth()
43 | .height(height = 56.dp)
44 | .background(color = colorResource(id = R.color.matisse_bottom_navigation_bar_background_color)),
45 | horizontalArrangement = Arrangement.SpaceBetween,
46 | verticalAlignment = Alignment.CenterVertically
47 | ) {
48 | Text(
49 | modifier = Modifier
50 | .then(
51 | other = if (viewState.previewButtonClickable) {
52 | Modifier
53 | .clip(shape = CircleShape)
54 | .clickable(onClick = viewState.onClickPreviewButton)
55 | } else {
56 | Modifier
57 | }
58 | )
59 | .padding(horizontal = 20.dp, vertical = 6.dp),
60 | text = viewState.previewButtonText,
61 | fontSize = 16.sp,
62 | fontStyle = FontStyle.Normal,
63 | fontWeight = FontWeight.Normal,
64 | color = if (viewState.previewButtonClickable) {
65 | colorResource(id = R.color.matisse_preview_text_color)
66 | } else {
67 | colorResource(id = R.color.matisse_preview_text_color_if_disable)
68 | }
69 | )
70 | Text(
71 | modifier = Modifier
72 | .then(
73 | other = if (viewState.sureButtonClickable) {
74 | Modifier
75 | .clip(shape = CircleShape)
76 | .clickable(onClick = onClickSure)
77 | } else {
78 | Modifier
79 | }
80 | )
81 | .padding(horizontal = 20.dp, vertical = 6.dp),
82 | text = viewState.sureButtonText,
83 | fontSize = 16.sp,
84 | fontStyle = FontStyle.Normal,
85 | fontWeight = FontWeight.Normal,
86 | color = colorResource(
87 | id = if (viewState.sureButtonClickable) {
88 | R.color.matisse_sure_text_color
89 | } else {
90 | R.color.matisse_sure_text_color_if_disable
91 | }
92 | )
93 | )
94 | }
95 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
12 |
13 |
16 |
19 |
22 |
25 |
28 |
31 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/BaseCaptureActivity.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal
2 |
3 | import android.Manifest
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.pm.PackageManager
7 | import android.net.Uri
8 | import android.provider.MediaStore
9 | import android.widget.Toast
10 | import androidx.activity.result.contract.ActivityResultContracts
11 | import androidx.annotation.StringRes
12 | import androidx.appcompat.app.AppCompatActivity
13 | import androidx.core.app.ActivityCompat
14 | import androidx.lifecycle.lifecycleScope
15 | import github.leavesczy.matisse.CaptureStrategy
16 | import github.leavesczy.matisse.MediaResource
17 | import github.leavesczy.matisse.R
18 | import github.leavesczy.matisse.internal.logic.MatisseTakePictureContract
19 | import kotlinx.coroutines.Dispatchers
20 | import kotlinx.coroutines.launch
21 | import kotlinx.coroutines.withContext
22 |
23 | /**
24 | * @Author: leavesCZY
25 | * @Date: 2023/10/21 16:49
26 | * @Desc:
27 | */
28 | internal abstract class BaseCaptureActivity : AppCompatActivity() {
29 |
30 | protected abstract val captureStrategy: CaptureStrategy
31 |
32 | private val requestWriteExternalStoragePermissionLauncher =
33 | registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
34 | if (granted) {
35 | requestCameraPermissionIfNeed()
36 | } else {
37 | showToast(id = R.string.matisse_write_external_storage_permission_denied)
38 | takePictureCancelled()
39 | }
40 | }
41 |
42 | private val requestCameraPermissionLauncher =
43 | registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
44 | if (granted) {
45 | takePicture()
46 | } else {
47 | showToast(id = R.string.matisse_camera_permission_denied)
48 | takePictureCancelled()
49 | }
50 | }
51 |
52 | private val takePictureLauncher =
53 | registerForActivityResult(MatisseTakePictureContract()) { successful ->
54 | takePictureResult(successful = successful)
55 | }
56 |
57 | private var tempImageUriForTakePicture: Uri? = null
58 |
59 | protected fun requestTakePicture() {
60 | if (captureStrategy.shouldRequestWriteExternalStoragePermission(context = applicationContext)) {
61 | requestWriteExternalStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
62 | } else {
63 | requestCameraPermissionIfNeed()
64 | }
65 | }
66 |
67 | private fun requestCameraPermissionIfNeed() {
68 | lifecycleScope.launch(context = Dispatchers.Main.immediate) {
69 | val cameraPermission = Manifest.permission.CAMERA
70 | val requirePermissionToTakePhotos = containsPermission(
71 | context = applicationContext,
72 | permission = cameraPermission
73 | ) && !permissionGranted(
74 | context = applicationContext,
75 | permission = cameraPermission
76 | )
77 | if (requirePermissionToTakePhotos) {
78 | requestCameraPermissionLauncher.launch(cameraPermission)
79 | } else {
80 | takePicture()
81 | }
82 | }
83 | }
84 |
85 | private fun takePicture() {
86 | lifecycleScope.launch(context = Dispatchers.Main.immediate) {
87 | tempImageUriForTakePicture = null
88 | val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
89 | if (captureIntent.resolveActivity(packageManager) != null) {
90 | val imageUri = captureStrategy.createImageUri(context = applicationContext)
91 | if (imageUri != null) {
92 | tempImageUriForTakePicture = imageUri
93 | takePictureLauncher.launch(
94 | MatisseTakePictureContract.MatisseTakePictureContractParams(
95 | uri = imageUri,
96 | extra = captureStrategy.getCaptureExtra()
97 | )
98 | )
99 | return@launch
100 | }
101 | } else {
102 | showToast(id = R.string.matisse_no_apps_support_take_picture)
103 | }
104 | takePictureCancelled()
105 | }
106 | }
107 |
108 | private fun takePictureResult(successful: Boolean) {
109 | lifecycleScope.launch(context = Dispatchers.Main.immediate) {
110 | val imageUri = tempImageUriForTakePicture
111 | tempImageUriForTakePicture = null
112 | if (imageUri != null) {
113 | if (successful) {
114 | val resource = captureStrategy.loadResource(
115 | context = applicationContext,
116 | imageUri = imageUri
117 | )
118 | if (resource != null) {
119 | dispatchTakePictureResult(mediaResource = resource)
120 | return@launch
121 | }
122 | } else {
123 | captureStrategy.onTakePictureCanceled(
124 | context = applicationContext,
125 | imageUri = imageUri
126 | )
127 | }
128 | }
129 | takePictureCancelled()
130 | }
131 | }
132 |
133 | protected abstract fun dispatchTakePictureResult(mediaResource: MediaResource)
134 |
135 | protected abstract fun takePictureCancelled()
136 |
137 | protected fun permissionGranted(context: Context, permissions: Array): Boolean {
138 | return permissions.all {
139 | permissionGranted(context = context, permission = it)
140 | }
141 | }
142 |
143 | private fun permissionGranted(context: Context, permission: String): Boolean {
144 | return ActivityCompat.checkSelfPermission(
145 | context,
146 | permission
147 | ) == PackageManager.PERMISSION_GRANTED
148 | }
149 |
150 | private suspend fun containsPermission(context: Context, permission: String): Boolean {
151 | return withContext(context = Dispatchers.Default) {
152 | try {
153 | val packageManager: PackageManager = context.packageManager
154 | val packageInfo = packageManager.getPackageInfo(
155 | context.packageName,
156 | PackageManager.GET_PERMISSIONS
157 | )
158 | val permissions = packageInfo.requestedPermissions
159 | if (!permissions.isNullOrEmpty()) {
160 | return@withContext permissions.contains(permission)
161 | }
162 | } catch (exception: PackageManager.NameNotFoundException) {
163 | exception.printStackTrace()
164 | }
165 | return@withContext false
166 | }
167 | }
168 |
169 | protected fun showToast(@StringRes id: Int) {
170 | showToast(text = getString(id))
171 | }
172 |
173 | protected fun showToast(text: String) {
174 | if (text.isNotBlank()) {
175 | Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
176 | }
177 | }
178 |
179 | }
--------------------------------------------------------------------------------
/app/src/main/java/github/leavesczy/matisse/samples/logic/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples.logic
2 |
3 | import android.net.Uri
4 | import android.os.Bundle
5 | import androidx.appcompat.app.AppCompatDelegate
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.setValue
9 | import androidx.lifecycle.ViewModel
10 | import github.leavesczy.matisse.CaptureStrategy
11 | import github.leavesczy.matisse.CoilImageEngine
12 | import github.leavesczy.matisse.DefaultMediaFilter
13 | import github.leavesczy.matisse.FileProviderCaptureStrategy
14 | import github.leavesczy.matisse.GlideImageEngine
15 | import github.leavesczy.matisse.Matisse
16 | import github.leavesczy.matisse.MatisseCapture
17 | import github.leavesczy.matisse.MediaResource
18 | import github.leavesczy.matisse.MediaStoreCaptureStrategy
19 | import github.leavesczy.matisse.MediaType
20 | import github.leavesczy.matisse.SmartCaptureStrategy
21 |
22 | /**
23 | * @Author: leavesCZY
24 | * @Date: 2024/2/21 12:01
25 | * @Desc:
26 | */
27 | class MainViewModel : ViewModel() {
28 |
29 | private var darkTheme by mutableStateOf(value = false)
30 |
31 | var pageViewState by mutableStateOf(
32 | value = MainPageViewState(
33 | gridColumns = 4,
34 | maxSelectable = 3,
35 | fastSelect = false,
36 | singleMediaType = false,
37 | imageEngine = MediaImageEngine.Coil,
38 | filterStrategy = MediaFilterStrategy.Nothing,
39 | captureStrategy = MediaCaptureStrategy.Smart,
40 | capturePreferencesCustom = false,
41 | mediaList = emptyList(),
42 | onGridColumnsChanged = ::onGridColumnsChanged,
43 | onMaxSelectableChanged = ::onMaxSelectableChanged,
44 | onFastSelectChanged = ::onFastSelectChanged,
45 | onSingleMediaTypeChanged = ::onSingleMediaTypeChanged,
46 | onImageEngineChanged = ::onImageEngineChanged,
47 | onFilterStrategyChanged = ::onFilterStrategyChanged,
48 | onCaptureStrategyChanged = ::onCaptureStrategyChanged,
49 | onCapturePreferencesCustomChanged = ::onCapturePreferencesCustomChanged,
50 | switchTheme = ::switchTheme
51 | )
52 | )
53 | private set
54 |
55 | private fun onGridColumnsChanged(gridColumns: Int) {
56 | pageViewState = pageViewState.copy(gridColumns = gridColumns)
57 | }
58 |
59 | private fun onMaxSelectableChanged(maxSelectable: Int) {
60 | val viewState = pageViewState
61 | val fastSelect = if (viewState.fastSelect) {
62 | maxSelectable == 1
63 | } else {
64 | false
65 | }
66 | pageViewState = viewState.copy(
67 | maxSelectable = maxSelectable,
68 | fastSelect = fastSelect
69 | )
70 | }
71 |
72 | private fun onFastSelectChanged(fastSelect: Boolean) {
73 | val viewState = pageViewState
74 | val maxSelectable = if (fastSelect) {
75 | 1
76 | } else {
77 | viewState.maxSelectable
78 | }
79 | pageViewState = viewState.copy(
80 | maxSelectable = maxSelectable,
81 | fastSelect = fastSelect
82 | )
83 | }
84 |
85 | private fun onSingleMediaTypeChanged(singleType: Boolean) {
86 | pageViewState = pageViewState.copy(singleMediaType = singleType)
87 | }
88 |
89 | private fun onImageEngineChanged(imageEngine: MediaImageEngine) {
90 | pageViewState = pageViewState.copy(imageEngine = imageEngine)
91 | }
92 |
93 | private fun onFilterStrategyChanged(filterStrategy: MediaFilterStrategy) {
94 | pageViewState = pageViewState.copy(filterStrategy = filterStrategy)
95 | }
96 |
97 | private fun onCaptureStrategyChanged(captureStrategy: MediaCaptureStrategy) {
98 | pageViewState = pageViewState.copy(captureStrategy = captureStrategy)
99 | }
100 |
101 | private fun onCapturePreferencesCustomChanged(custom: Boolean) {
102 | pageViewState = pageViewState.copy(capturePreferencesCustom = custom)
103 | }
104 |
105 | private fun switchTheme() {
106 | darkTheme = !darkTheme
107 | if (darkTheme) {
108 | AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
109 | } else {
110 | AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
111 | }
112 | }
113 |
114 | private fun getMediaCaptureStrategy(): CaptureStrategy? {
115 | val viewState = pageViewState
116 | val fileProviderAuthority = "github.leavesczy.matisse.samples.FileProvider"
117 | val captureExtra = if (viewState.capturePreferencesCustom) {
118 | val bundle = Bundle()
119 | bundle.putBoolean("android.intent.extra.USE_FRONT_CAMERA", true)
120 | bundle.putInt("android.intent.extras.CAMERA_FACING", 1)
121 | bundle
122 | } else {
123 | Bundle.EMPTY
124 | }
125 | return when (viewState.captureStrategy) {
126 | MediaCaptureStrategy.Smart -> {
127 | SmartCaptureStrategy(
128 | fileProviderCaptureStrategy = FileProviderCaptureStrategy(
129 | authority = fileProviderAuthority,
130 | extra = captureExtra
131 | )
132 | )
133 | }
134 |
135 | MediaCaptureStrategy.FileProvider -> {
136 | FileProviderCaptureStrategy(
137 | authority = fileProviderAuthority,
138 | extra = captureExtra
139 | )
140 | }
141 |
142 | MediaCaptureStrategy.MediaStore -> {
143 | MediaStoreCaptureStrategy(extra = captureExtra)
144 | }
145 |
146 | MediaCaptureStrategy.Close -> {
147 | null
148 | }
149 | }
150 | }
151 |
152 | fun buildMatisse(mediaType: MediaType): Matisse {
153 | val viewState = pageViewState
154 | val imageEngine = when (viewState.imageEngine) {
155 | MediaImageEngine.Coil -> {
156 | CoilImageEngine()
157 | }
158 |
159 | MediaImageEngine.Glide -> {
160 | GlideImageEngine()
161 | }
162 | }
163 | val ignoredResourceUri: Set
164 | val selectedResourceUri: Set
165 | when (viewState.filterStrategy) {
166 | MediaFilterStrategy.Nothing -> {
167 | ignoredResourceUri = emptySet()
168 | selectedResourceUri = emptySet()
169 | }
170 |
171 | MediaFilterStrategy.IgnoreSelected -> {
172 | ignoredResourceUri = viewState.mediaList.map { it.uri }.toSet()
173 | selectedResourceUri = emptySet()
174 | }
175 |
176 | MediaFilterStrategy.AttachSelected -> {
177 | ignoredResourceUri = emptySet()
178 | selectedResourceUri = viewState.mediaList.map { it.uri }.toSet()
179 | }
180 | }
181 | val mediaFilter = DefaultMediaFilter(
182 | ignoredMimeType = emptySet(),
183 | ignoredResourceUri = ignoredResourceUri,
184 | selectedResourceUri = selectedResourceUri
185 | )
186 | return Matisse(
187 | gridColumns = viewState.gridColumns,
188 | maxSelectable = viewState.maxSelectable,
189 | fastSelect = viewState.fastSelect,
190 | mediaType = mediaType,
191 | mediaFilter = mediaFilter,
192 | imageEngine = imageEngine,
193 | singleMediaType = viewState.singleMediaType,
194 | captureStrategy = getMediaCaptureStrategy()
195 | )
196 | }
197 |
198 | fun buildMediaCaptureStrategy(): MatisseCapture? {
199 | val captureStrategy = getMediaCaptureStrategy() ?: return null
200 | return MatisseCapture(captureStrategy = captureStrategy)
201 | }
202 |
203 | fun takePictureResult(result: MediaResource?) {
204 | if (result != null) {
205 | pageViewState = pageViewState.copy(mediaList = listOf(element = result))
206 | }
207 | }
208 |
209 | fun mediaPickerResult(result: List?) {
210 | if (!result.isNullOrEmpty()) {
211 | pageViewState = pageViewState.copy(mediaList = result)
212 | }
213 | }
214 |
215 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/MatisseActivity.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal
2 |
3 | import android.Manifest
4 | import android.content.Intent
5 | import android.os.Build
6 | import android.os.Bundle
7 | import android.os.Parcelable
8 | import androidx.activity.compose.setContent
9 | import androidx.activity.result.contract.ActivityResultContracts
10 | import androidx.activity.viewModels
11 | import androidx.compose.runtime.LaunchedEffect
12 | import androidx.compose.runtime.snapshotFlow
13 | import androidx.compose.ui.Modifier
14 | import androidx.core.content.IntentCompat
15 | import androidx.core.view.WindowCompat
16 | import androidx.core.view.WindowInsetsCompat
17 | import androidx.core.view.WindowInsetsControllerCompat
18 | import androidx.lifecycle.ViewModel
19 | import androidx.lifecycle.ViewModelProvider
20 | import github.leavesczy.matisse.CaptureStrategy
21 | import github.leavesczy.matisse.Matisse
22 | import github.leavesczy.matisse.MediaResource
23 | import github.leavesczy.matisse.R
24 | import github.leavesczy.matisse.internal.logic.MatisseViewModel
25 | import github.leavesczy.matisse.internal.ui.MatisseLoadingDialog
26 | import github.leavesczy.matisse.internal.ui.MatissePage
27 | import github.leavesczy.matisse.internal.ui.MatissePreviewPage
28 | import github.leavesczy.matisse.internal.ui.MatisseTheme
29 | import kotlinx.coroutines.flow.collectLatest
30 |
31 | /**
32 | * @Author: leavesCZY
33 | * @Date: 2022/5/28 22:28
34 | * @Desc:
35 | */
36 | internal class MatisseActivity : BaseCaptureActivity() {
37 |
38 | private val matisseViewModel by viewModels(factoryProducer = {
39 | object : ViewModelProvider.Factory {
40 | @Suppress("UNCHECKED_CAST")
41 | override fun create(modelClass: Class): T {
42 | return MatisseViewModel(
43 | application = application,
44 | matisse = IntentCompat.getParcelableExtra(
45 | intent,
46 | Matisse::class.java.name,
47 | Matisse::class.java
48 | )!!
49 | ) as T
50 | }
51 | }
52 | })
53 |
54 | private val requestReadMediaPermissionLauncher =
55 | registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
56 | matisseViewModel.requestReadMediaPermissionResult(
57 | granted = result.all {
58 | it.value
59 | }
60 | )
61 | }
62 |
63 | override val captureStrategy: CaptureStrategy
64 | get() = requireNotNull(value = matisseViewModel.captureStrategy)
65 |
66 | override fun onCreate(savedInstanceState: Bundle?) {
67 | setSystemBarUi(previewPageVisible = false)
68 | super.onCreate(savedInstanceState)
69 | setContent {
70 | LaunchedEffect(key1 = Unit) {
71 | snapshotFlow {
72 | matisseViewModel.previewPageViewState.visible
73 | }.collectLatest {
74 | setSystemBarUi(previewPageVisible = it)
75 | }
76 | }
77 | MatisseTheme {
78 | MatissePage(
79 | pageViewState = matisseViewModel.pageViewState,
80 | bottomBarViewState = matisseViewModel.bottomBarViewState,
81 | onRequestTakePicture = ::requestTakePicture,
82 | onClickSure = ::onClickSure,
83 | selectMediaInFastSelectMode = ::selectMediaInFastSelectMode
84 | )
85 | MatissePreviewPage(
86 | pageViewState = matisseViewModel.previewPageViewState,
87 | imageEngine = matisseViewModel.pageViewState.imageEngine,
88 | requestOpenVideo = ::requestOpenVideo,
89 | onClickSure = ::onClickSure
90 | )
91 | MatisseLoadingDialog(
92 | modifier = Modifier,
93 | visible = matisseViewModel.loadingDialogVisible
94 | )
95 | }
96 | }
97 | requestReadMediaPermission()
98 | }
99 |
100 | private fun requestReadMediaPermission() {
101 | val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
102 | && applicationInfo.targetSdkVersion >= Build.VERSION_CODES.TIRAMISU
103 | ) {
104 | buildList {
105 | val mediaType = matisseViewModel.mediaType
106 | if (mediaType.includeImage) {
107 | add(element = Manifest.permission.READ_MEDIA_IMAGES)
108 | }
109 | if (mediaType.includeVideo) {
110 | add(element = Manifest.permission.READ_MEDIA_VIDEO)
111 | }
112 | }.toTypedArray()
113 | } else {
114 | arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
115 | }
116 | if (permissionGranted(context = this, permissions = permissions)) {
117 | matisseViewModel.requestReadMediaPermissionResult(granted = true)
118 | } else {
119 | requestReadMediaPermissionLauncher.launch(permissions)
120 | }
121 | }
122 |
123 | private fun requestOpenVideo(mediaResource: MediaResource) {
124 | val intent = Intent(this, MatisseVideoViewActivity::class.java)
125 | intent.putExtra(MediaResource::class.java.name, mediaResource)
126 | startActivity(intent)
127 | }
128 |
129 | override fun dispatchTakePictureResult(mediaResource: MediaResource) {
130 | val maxSelectable = matisseViewModel.maxSelectable
131 | val selectedResources = matisseViewModel.filterSelectedMedia()
132 | val illegalMediaType = matisseViewModel.singleMediaType && selectedResources.any {
133 | it.isVideo
134 | }
135 | if (maxSelectable > 1 && (selectedResources.size in 1..) {
166 | if (selected.isEmpty()) {
167 | setResult(RESULT_CANCELED)
168 | } else {
169 | val data = Intent()
170 | val resources = arrayListOf().apply {
171 | addAll(selected)
172 | }
173 | data.putParcelableArrayListExtra(MediaResource::class.java.name, resources)
174 | setResult(RESULT_OK, data)
175 | }
176 | finish()
177 | }
178 |
179 | private fun setSystemBarUi(previewPageVisible: Boolean) {
180 | WindowCompat.setDecorFitsSystemWindows(window, false)
181 | WindowInsetsControllerCompat(window, window.decorView).apply {
182 | systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
183 | val types = WindowInsetsCompat.Type.statusBars()
184 | if (previewPageVisible) {
185 | hide(types)
186 | } else {
187 | show(types)
188 | }
189 | val statusBarDarkIcons: Boolean
190 | val navigationBarDarkIcons: Boolean
191 | if (previewPageVisible) {
192 | statusBarDarkIcons = false
193 | navigationBarDarkIcons = false
194 | } else {
195 | statusBarDarkIcons = resources.getBoolean(R.bool.matisse_status_bar_dark_icons)
196 | navigationBarDarkIcons =
197 | resources.getBoolean(R.bool.matisse_navigation_bar_dark_icons)
198 | }
199 | isAppearanceLightStatusBars = statusBarDarkIcons
200 | isAppearanceLightNavigationBars = navigationBarDarkIcons
201 | }
202 | }
203 |
204 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/CaptureStrategy.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse
2 |
3 | import android.Manifest
4 | import android.content.Context
5 | import android.content.pm.PackageManager
6 | import android.net.Uri
7 | import android.os.Build
8 | import android.os.Bundle
9 | import android.os.Environment
10 | import android.os.Parcelable
11 | import androidx.compose.runtime.Stable
12 | import androidx.core.app.ActivityCompat
13 | import androidx.core.content.FileProvider
14 | import github.leavesczy.matisse.internal.logic.MediaProvider
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.delay
17 | import kotlinx.coroutines.withContext
18 | import kotlinx.parcelize.IgnoredOnParcel
19 | import kotlinx.parcelize.Parcelize
20 | import java.io.File
21 | import java.text.SimpleDateFormat
22 | import java.util.Date
23 | import java.util.Locale
24 |
25 | /**
26 | * @Author: leavesCZY
27 | * @Date: 2022/6/6 14:20
28 | * @Desc:
29 | */
30 | /**
31 | * 拍照策略
32 | */
33 | @Stable
34 | interface CaptureStrategy : Parcelable {
35 |
36 | /**
37 | * 是否需要申请 WRITE_EXTERNAL_STORAGE 权限
38 | */
39 | fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean
40 |
41 | /**
42 | * 生成图片 Uri
43 | */
44 | suspend fun createImageUri(context: Context): Uri?
45 |
46 | /**
47 | * 获取拍照结果
48 | */
49 | suspend fun loadResource(context: Context, imageUri: Uri): MediaResource?
50 |
51 | /**
52 | * 当用户取消拍照时调用
53 | */
54 | suspend fun onTakePictureCanceled(context: Context, imageUri: Uri)
55 |
56 | /**
57 | * 生成图片名
58 | */
59 | suspend fun createImageName(context: Context): String {
60 | return withContext(context = Dispatchers.Default) {
61 | val time = SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US).format(Date())
62 | "IMG_$time.jpg"
63 | }
64 | }
65 |
66 | /**
67 | * 用于为相机设置启动参数
68 | * 返回值会传递给启动相机的 Intent
69 | */
70 | fun getCaptureExtra(): Bundle {
71 | return Bundle.EMPTY
72 | }
73 |
74 | }
75 |
76 | private const val JPG_MIME_TYPE = "image/jpeg"
77 |
78 | /**
79 | * 通过 FileProvider 生成 ImageUri
80 | * 外部必须配置 FileProvider,通过 authority 来实例化 FileProviderCaptureStrategy
81 | * 此策略无需申请任何权限,所拍的照片不会保存在系统相册里
82 | */
83 | @Parcelize
84 | open class FileProviderCaptureStrategy(
85 | private val authority: String,
86 | private val extra: Bundle = Bundle.EMPTY
87 | ) : CaptureStrategy {
88 |
89 | @IgnoredOnParcel
90 | private val uriFileMap = mutableMapOf()
91 |
92 | final override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean {
93 | return false
94 | }
95 |
96 | final override suspend fun createImageUri(context: Context): Uri? {
97 | return withContext(context = Dispatchers.Main.immediate) {
98 | val tempFile = createTempFile(context = context)
99 | if (tempFile != null) {
100 | val uri = FileProvider.getUriForFile(context, authority, tempFile)
101 | uriFileMap[uri] = tempFile
102 | uri
103 | } else {
104 | null
105 | }
106 | }
107 | }
108 |
109 | private suspend fun createTempFile(context: Context): File? {
110 | return withContext(context = Dispatchers.IO) {
111 | val picturesDirectory = getAuthorityDirectory(context = context)
112 | val file = File(picturesDirectory, createImageName(context = context))
113 | if (file.createNewFile()) {
114 | file
115 | } else {
116 | null
117 | }
118 | }
119 | }
120 |
121 | protected open fun getAuthorityDirectory(context: Context): File {
122 | return context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
123 | }
124 |
125 | final override suspend fun loadResource(context: Context, imageUri: Uri): MediaResource {
126 | return withContext(context = Dispatchers.Main.immediate) {
127 | val imageFile = uriFileMap[imageUri]!!
128 | uriFileMap.remove(key = imageUri)
129 | MediaResource(
130 | uri = imageUri,
131 | path = imageFile.absolutePath,
132 | name = imageFile.name,
133 | mimeType = JPG_MIME_TYPE,
134 | size = MediaProvider.getFileRealSize(context = context, uri = imageUri) ?: 0L
135 | )
136 | }
137 | }
138 |
139 | final override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) {
140 | withContext(context = Dispatchers.Main.immediate) {
141 | val imageFile = uriFileMap[imageUri]
142 | uriFileMap.remove(key = imageUri)
143 | withContext(context = Dispatchers.IO) {
144 | if (imageFile != null && imageFile.exists()) {
145 | imageFile.delete()
146 | }
147 | }
148 | }
149 | }
150 |
151 | final override fun getCaptureExtra(): Bundle {
152 | return extra
153 | }
154 |
155 | }
156 |
157 | /**
158 | * 通过 MediaStore 生成 ImageUri
159 | * 根据系统版本决定是否需要申请 WRITE_EXTERNAL_STORAGE 权限
160 | * 所拍的照片会保存在系统相册中
161 | */
162 | @Parcelize
163 | data class MediaStoreCaptureStrategy(private val extra: Bundle = Bundle.EMPTY) : CaptureStrategy {
164 |
165 | override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean {
166 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
167 | return false
168 | }
169 | return ActivityCompat.checkSelfPermission(
170 | context,
171 | Manifest.permission.WRITE_EXTERNAL_STORAGE
172 | ) == PackageManager.PERMISSION_DENIED
173 | }
174 |
175 | override suspend fun createImageUri(context: Context): Uri? {
176 | return MediaProvider.createImage(
177 | context = context,
178 | imageName = createImageName(context = context),
179 | mimeType = JPG_MIME_TYPE
180 | )
181 | }
182 |
183 | override suspend fun loadResource(context: Context, imageUri: Uri): MediaResource? {
184 | return withContext(context = Dispatchers.Default) {
185 | repeat(times = 10) {
186 | val result = loadResources(context = context, uri = imageUri)
187 | if (result != null) {
188 | return@withContext result
189 | }
190 | delay(timeMillis = 50L)
191 | }
192 | return@withContext null
193 | }
194 | }
195 |
196 | private suspend fun loadResources(context: Context, uri: Uri): MediaResource? {
197 | val resource = MediaProvider.loadResources(context = context, uri = uri)
198 | return if (resource == null) {
199 | null
200 | } else {
201 | MediaResource(
202 | uri = resource.uri,
203 | path = resource.path,
204 | name = resource.name,
205 | mimeType = resource.mimeType,
206 | size = MediaProvider.getFileRealSize(context = context, uri = resource.uri) ?: 0L
207 | )
208 | }
209 | }
210 |
211 | override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) {
212 | MediaProvider.deleteMedia(context = context, uri = imageUri)
213 | }
214 |
215 | override fun getCaptureExtra(): Bundle {
216 | return extra
217 | }
218 |
219 | }
220 |
221 | /**
222 | * 根据系统版本智能选择拍照策略
223 | * 当系统版本小于 Android 10 时,执行 FileProviderCaptureStrategy 策略
224 | * 当系统版本大于等于 Android 10 时,执行 MediaStoreCaptureStrategy 策略
225 | * 既避免需要申请权限,又可以在系统允许的情况下将照片存入到系统相册中
226 | */
227 | @Parcelize
228 | data class SmartCaptureStrategy(
229 | private val fileProviderCaptureStrategy: FileProviderCaptureStrategy
230 | ) : CaptureStrategy {
231 |
232 | @IgnoredOnParcel
233 | private val proxy = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
234 | MediaStoreCaptureStrategy(extra = fileProviderCaptureStrategy.getCaptureExtra())
235 | } else {
236 | fileProviderCaptureStrategy
237 | }
238 |
239 | override fun shouldRequestWriteExternalStoragePermission(context: Context): Boolean {
240 | return proxy.shouldRequestWriteExternalStoragePermission(context = context)
241 | }
242 |
243 | override suspend fun createImageUri(context: Context): Uri? {
244 | return proxy.createImageUri(context = context)
245 | }
246 |
247 | override suspend fun loadResource(context: Context, imageUri: Uri): MediaResource? {
248 | return proxy.loadResource(context = context, imageUri = imageUri)
249 | }
250 |
251 | override suspend fun onTakePictureCanceled(context: Context, imageUri: Uri) {
252 | proxy.onTakePictureCanceled(context = context, imageUri = imageUri)
253 | }
254 |
255 | override suspend fun createImageName(context: Context): String {
256 | return proxy.createImageName(context = context)
257 | }
258 |
259 | override fun getCaptureExtra(): Bundle {
260 | return proxy.getCaptureExtra()
261 | }
262 |
263 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatissePreviewPage.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.animation.AnimatedVisibility
5 | import androidx.compose.animation.core.FastOutSlowInEasing
6 | import androidx.compose.animation.core.tween
7 | import androidx.compose.animation.slideInHorizontally
8 | import androidx.compose.animation.slideOutHorizontally
9 | import androidx.compose.foundation.background
10 | import androidx.compose.foundation.clickable
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.Column
13 | import androidx.compose.foundation.layout.WindowInsets
14 | import androidx.compose.foundation.layout.fillMaxSize
15 | import androidx.compose.foundation.layout.fillMaxWidth
16 | import androidx.compose.foundation.layout.height
17 | import androidx.compose.foundation.layout.navigationBarsPadding
18 | import androidx.compose.foundation.layout.padding
19 | import androidx.compose.foundation.layout.size
20 | import androidx.compose.foundation.pager.HorizontalPager
21 | import androidx.compose.foundation.pager.PagerState
22 | import androidx.compose.foundation.pager.rememberPagerState
23 | import androidx.compose.foundation.shape.CircleShape
24 | import androidx.compose.material3.Scaffold
25 | import androidx.compose.material3.Text
26 | import androidx.compose.runtime.Composable
27 | import androidx.compose.runtime.derivedStateOf
28 | import androidx.compose.runtime.getValue
29 | import androidx.compose.runtime.remember
30 | import androidx.compose.ui.Alignment
31 | import androidx.compose.ui.Modifier
32 | import androidx.compose.ui.draw.clip
33 | import androidx.compose.ui.graphics.graphicsLayer
34 | import androidx.compose.ui.res.colorResource
35 | import androidx.compose.ui.res.stringResource
36 | import androidx.compose.ui.text.font.FontStyle
37 | import androidx.compose.ui.text.font.FontWeight
38 | import androidx.compose.ui.unit.dp
39 | import androidx.compose.ui.unit.sp
40 | import androidx.compose.ui.util.lerp
41 | import github.leavesczy.matisse.ImageEngine
42 | import github.leavesczy.matisse.MediaResource
43 | import github.leavesczy.matisse.R
44 | import github.leavesczy.matisse.internal.logic.MatissePreviewPageViewState
45 | import kotlin.math.absoluteValue
46 |
47 | /**
48 | * @Author: leavesCZY
49 | * @Date: 2022/6/1 19:14
50 | * @Desc:
51 | */
52 | @Composable
53 | internal fun MatissePreviewPage(
54 | pageViewState: MatissePreviewPageViewState,
55 | imageEngine: ImageEngine,
56 | requestOpenVideo: (MediaResource) -> Unit,
57 | onClickSure: () -> Unit
58 | ) {
59 | AnimatedVisibility(
60 | modifier = Modifier
61 | .fillMaxSize(),
62 | visible = pageViewState.visible,
63 | enter = slideInHorizontally(
64 | animationSpec = tween(
65 | durationMillis = 350,
66 | easing = FastOutSlowInEasing
67 | ),
68 | initialOffsetX = { it }
69 | ),
70 | exit = slideOutHorizontally(
71 | animationSpec = tween(
72 | durationMillis = 350,
73 | easing = FastOutSlowInEasing
74 | ),
75 | targetOffsetX = { it }
76 | )
77 | ) {
78 | BackHandler(
79 | enabled = pageViewState.visible,
80 | onBack = pageViewState.onDismissRequest
81 | )
82 | val pagerState = rememberPagerState(initialPage = pageViewState.initialPage) {
83 | pageViewState.previewResources.size
84 | }
85 | Scaffold(
86 | modifier = Modifier
87 | .fillMaxSize(),
88 | contentWindowInsets = WindowInsets(),
89 | containerColor = colorResource(id = R.color.matisse_preview_page_background_color)
90 | ) { paddingValues ->
91 | Column(
92 | modifier = Modifier
93 | .padding(paddingValues = paddingValues)
94 | .fillMaxSize()
95 | ) {
96 | HorizontalPager(
97 | modifier = Modifier
98 | .fillMaxWidth()
99 | .weight(weight = 1f),
100 | state = pagerState,
101 | key = { index ->
102 | pageViewState.previewResources[index].mediaId
103 | }
104 | ) { pageIndex ->
105 | PreviewPage(
106 | modifier = Modifier
107 | .fillMaxSize(),
108 | pagerState = pagerState,
109 | pageIndex = pageIndex,
110 | imageEngine = imageEngine,
111 | mediaResource = pageViewState.previewResources[pageIndex].media,
112 | requestOpenVideo = requestOpenVideo
113 | )
114 | }
115 | BottomController(
116 | modifier = Modifier
117 | .fillMaxWidth(),
118 | pageViewState = pageViewState,
119 | pagerState = pagerState,
120 | onClickSure = onClickSure
121 | )
122 | }
123 | }
124 | }
125 | }
126 |
127 | @Composable
128 | private fun PreviewPage(
129 | modifier: Modifier,
130 | pagerState: PagerState,
131 | pageIndex: Int,
132 | imageEngine: ImageEngine,
133 | mediaResource: MediaResource,
134 | requestOpenVideo: (MediaResource) -> Unit
135 | ) {
136 | val fraction by remember {
137 | derivedStateOf {
138 | val pageOffset =
139 | (pagerState.currentPage - pageIndex + pagerState.currentPageOffsetFraction).absoluteValue
140 | val progress = 1f - pageOffset.coerceIn(0f, 1f)
141 | lerp(
142 | start = 0.80f,
143 | stop = 1f,
144 | fraction = progress
145 | )
146 | }
147 | }
148 | Box(
149 | modifier = modifier,
150 | contentAlignment = Alignment.Center
151 | ) {
152 | Box(
153 | modifier = Modifier
154 | .graphicsLayer {
155 | scaleX = fraction
156 | scaleY = fraction
157 | alpha = fraction
158 | },
159 | contentAlignment = Alignment.Center
160 | ) {
161 | imageEngine.Image(mediaResource = mediaResource)
162 | if (mediaResource.isVideo) {
163 | VideoIcon(
164 | modifier = Modifier
165 | .clip(shape = CircleShape)
166 | .clickable {
167 | requestOpenVideo(mediaResource)
168 | }
169 | .padding(all = 10.dp)
170 | .size(size = 50.dp)
171 | )
172 | }
173 | }
174 | }
175 | }
176 |
177 | @Composable
178 | private fun BottomController(
179 | modifier: Modifier,
180 | pageViewState: MatissePreviewPageViewState,
181 | pagerState: PagerState,
182 | onClickSure: () -> Unit
183 | ) {
184 | val currentResource by remember {
185 | derivedStateOf {
186 | pageViewState.previewResources[pagerState.currentPage]
187 | }
188 | }
189 | Box(
190 | modifier = modifier
191 | .background(color = colorResource(id = R.color.matisse_preview_page_bottom_navigation_bar_background_color))
192 | .navigationBarsPadding()
193 | .fillMaxWidth()
194 | .height(height = 56.dp)
195 | ) {
196 | Text(
197 | modifier = Modifier
198 | .align(alignment = Alignment.CenterStart)
199 | .clip(shape = CircleShape)
200 | .clickable(onClick = pageViewState.onDismissRequest)
201 | .padding(horizontal = 20.dp, vertical = 6.dp),
202 | text = stringResource(id = R.string.matisse_back),
203 | fontSize = 16.sp,
204 | fontStyle = FontStyle.Normal,
205 | fontWeight = FontWeight.Normal,
206 | color = colorResource(id = R.color.matisse_preview_page_back_text_color)
207 | )
208 | MatisseCheckbox(
209 | modifier = Modifier
210 | .align(alignment = Alignment.Center)
211 | .size(size = 24.dp),
212 | selectState = currentResource.selectState.value,
213 | onClick = {
214 | pageViewState.onMediaCheckChanged(currentResource)
215 | }
216 | )
217 | Text(
218 | modifier = Modifier
219 | .align(alignment = Alignment.CenterEnd)
220 | .then(
221 | other = if (pageViewState.sureButtonClickable) {
222 | Modifier
223 | .clip(shape = CircleShape)
224 | .clickable(onClick = onClickSure)
225 | } else {
226 | Modifier
227 | }
228 | )
229 | .padding(horizontal = 20.dp, vertical = 6.dp),
230 | text = pageViewState.sureButtonText,
231 | fontSize = 16.sp,
232 | fontStyle = FontStyle.Normal,
233 | fontWeight = FontWeight.Normal,
234 | color = colorResource(
235 | id = if (pageViewState.sureButtonClickable) {
236 | R.color.matisse_preview_page_sure_text_color
237 | } else {
238 | R.color.matisse_preview_page_sure_text_color_if_disable
239 | }
240 | )
241 | )
242 | }
243 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatisseTopBar.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.PaddingValues
9 | import androidx.compose.foundation.layout.Row
10 | import androidx.compose.foundation.layout.Spacer
11 | import androidx.compose.foundation.layout.WindowInsets
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.fillMaxWidth
14 | import androidx.compose.foundation.layout.height
15 | import androidx.compose.foundation.layout.padding
16 | import androidx.compose.foundation.layout.size
17 | import androidx.compose.foundation.layout.sizeIn
18 | import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
19 | import androidx.compose.foundation.layout.windowInsetsPadding
20 | import androidx.compose.foundation.shape.RoundedCornerShape
21 | import androidx.compose.material.icons.Icons
22 | import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
23 | import androidx.compose.material.icons.filled.ArrowDropDown
24 | import androidx.compose.material3.DropdownMenu
25 | import androidx.compose.material3.DropdownMenuItem
26 | import androidx.compose.material3.Icon
27 | import androidx.compose.material3.Text
28 | import androidx.compose.runtime.Composable
29 | import androidx.compose.runtime.getValue
30 | import androidx.compose.runtime.mutableStateOf
31 | import androidx.compose.runtime.remember
32 | import androidx.compose.runtime.rememberCoroutineScope
33 | import androidx.compose.runtime.setValue
34 | import androidx.compose.ui.Alignment
35 | import androidx.compose.ui.Modifier
36 | import androidx.compose.ui.draw.clip
37 | import androidx.compose.ui.res.colorResource
38 | import androidx.compose.ui.text.font.FontStyle
39 | import androidx.compose.ui.text.font.FontWeight
40 | import androidx.compose.ui.text.style.TextAlign
41 | import androidx.compose.ui.text.style.TextOverflow
42 | import androidx.compose.ui.unit.DpOffset
43 | import androidx.compose.ui.unit.dp
44 | import androidx.compose.ui.unit.sp
45 | import github.leavesczy.matisse.ImageEngine
46 | import github.leavesczy.matisse.R
47 | import github.leavesczy.matisse.internal.logic.MatisseMediaBucketInfo
48 | import kotlinx.coroutines.launch
49 |
50 | /**
51 | * @Author: leavesCZY
52 | * @Date: 2021/6/24 16:44
53 | * @Desc:
54 | */
55 | @Composable
56 | internal fun MatisseTopBar(
57 | modifier: Modifier,
58 | title: String,
59 | mediaBucketsInfo: List,
60 | onClickBucket: suspend (String) -> Unit,
61 | imageEngine: ImageEngine
62 | ) {
63 | var menuExpanded by remember {
64 | mutableStateOf(value = false)
65 | }
66 | val coroutineScope = rememberCoroutineScope()
67 | Column(
68 | modifier = modifier
69 | .fillMaxWidth(),
70 | horizontalAlignment = Alignment.CenterHorizontally,
71 | verticalArrangement = Arrangement.Top
72 | ) {
73 | StatusBar(modifier = Modifier)
74 | MatisseTopBar(
75 | modifier = Modifier,
76 | title = title,
77 | openDropdownMenu = {
78 | menuExpanded = true
79 | }
80 | )
81 | BucketDropdownMenu(
82 | modifier = Modifier,
83 | expanded = menuExpanded,
84 | mediaBuckets = mediaBucketsInfo,
85 | imageEngine = imageEngine,
86 | onClickBucket = {
87 | menuExpanded = false
88 | coroutineScope.launch {
89 | onClickBucket(it.bucketId)
90 | }
91 | },
92 | onDismissRequest = {
93 | menuExpanded = false
94 | }
95 | )
96 | }
97 | }
98 |
99 | @Composable
100 | private fun StatusBar(modifier: Modifier) {
101 | Spacer(
102 | modifier = modifier
103 | .fillMaxWidth()
104 | .background(color = colorResource(id = R.color.matisse_status_bar_color))
105 | .windowInsetsPadding(insets = WindowInsets.statusBarsIgnoringVisibility)
106 | )
107 | }
108 |
109 | @Composable
110 | private fun MatisseTopBar(
111 | modifier: Modifier,
112 | title: String,
113 | openDropdownMenu: () -> Unit
114 | ) {
115 | Row(
116 | modifier = modifier
117 | .fillMaxWidth()
118 | .height(height = 52.dp)
119 | .background(color = colorResource(id = R.color.matisse_top_bar_background_color)),
120 | horizontalArrangement = Arrangement.Start,
121 | verticalAlignment = Alignment.CenterVertically
122 | ) {
123 | val onBackPressedDispatcher =
124 | LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
125 | Icon(
126 | modifier = Modifier
127 | .clickableNoRipple {
128 | onBackPressedDispatcher?.onBackPressed()
129 | }
130 | .padding(start = 18.dp, end = 14.dp)
131 | .size(size = 22.dp),
132 | imageVector = Icons.AutoMirrored.Filled.ArrowBackIos,
133 | tint = colorResource(id = R.color.matisse_top_bar_icon_color),
134 | contentDescription = null
135 | )
136 | Text(
137 | modifier = Modifier
138 | .weight(weight = 1f, fill = false)
139 | .clickableNoRipple(onClick = openDropdownMenu),
140 | text = title,
141 | fontSize = 20.sp,
142 | maxLines = 1,
143 | overflow = TextOverflow.Ellipsis,
144 | textAlign = TextAlign.Start,
145 | fontStyle = FontStyle.Normal,
146 | fontWeight = FontWeight.Normal,
147 | color = colorResource(id = R.color.matisse_top_bar_text_color)
148 | )
149 | Icon(
150 | modifier = Modifier
151 | .clickableNoRipple(onClick = openDropdownMenu)
152 | .padding(start = 14.dp)
153 | .size(size = 32.dp),
154 | imageVector = Icons.Filled.ArrowDropDown,
155 | tint = colorResource(id = R.color.matisse_top_bar_icon_color),
156 | contentDescription = null
157 | )
158 | }
159 | }
160 |
161 | @Composable
162 | private fun BucketDropdownMenu(
163 | modifier: Modifier,
164 | expanded: Boolean,
165 | mediaBuckets: List,
166 | imageEngine: ImageEngine,
167 | onClickBucket: (MatisseMediaBucketInfo) -> Unit,
168 | onDismissRequest: () -> Unit
169 | ) {
170 | DropdownMenu(
171 | modifier = modifier
172 | .background(color = colorResource(id = R.color.matisse_dropdown_menu_background_color))
173 | .sizeIn(minWidth = 180.dp, maxHeight = 400.dp),
174 | expanded = expanded,
175 | offset = DpOffset(x = 10.dp, y = (-10).dp),
176 | onDismissRequest = onDismissRequest
177 | ) {
178 | for (bucket in mediaBuckets) {
179 | DropdownMenuItem(
180 | modifier = Modifier
181 | .fillMaxWidth(),
182 | contentPadding = PaddingValues(
183 | horizontal = 6.dp,
184 | vertical = 4.dp
185 | ),
186 | text = {
187 | Row(
188 | modifier = Modifier,
189 | verticalAlignment = Alignment.CenterVertically
190 | ) {
191 | Box(
192 | modifier = Modifier
193 | .size(size = 52.dp)
194 | .clip(shape = RoundedCornerShape(size = 2.dp)),
195 | contentAlignment = Alignment.Center
196 | ) {
197 | val firstMedia = bucket.firstMedia
198 | if (firstMedia != null) {
199 | imageEngine.Thumbnail(mediaResource = firstMedia)
200 | } else {
201 | Spacer(
202 | modifier = Modifier
203 | .fillMaxSize()
204 | .background(color = colorResource(id = R.color.matisse_media_item_background_color))
205 | )
206 | }
207 | }
208 | Text(
209 | modifier = Modifier
210 | .weight(weight = 1f, fill = false)
211 | .padding(start = 10.dp),
212 | text = bucket.bucketName,
213 | fontSize = 15.sp,
214 | maxLines = 2,
215 | overflow = TextOverflow.Ellipsis,
216 | fontStyle = FontStyle.Normal,
217 | fontWeight = FontWeight.Normal,
218 | color = colorResource(id = R.color.matisse_dropdown_menu_text_color)
219 | )
220 | Text(
221 | modifier = Modifier
222 | .padding(start = 6.dp, end = 6.dp),
223 | text = "(${bucket.size})",
224 | fontSize = 15.sp,
225 | maxLines = 1,
226 | overflow = TextOverflow.Ellipsis,
227 | fontStyle = FontStyle.Normal,
228 | fontWeight = FontWeight.Normal,
229 | color = colorResource(id = R.color.matisse_dropdown_menu_text_color)
230 | )
231 | }
232 | },
233 | onClick = {
234 | onClickBucket(bucket)
235 | }
236 | )
237 | }
238 | }
239 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/logic/MediaProvider.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.logic
2 |
3 | import android.content.ContentUris
4 | import android.content.ContentValues
5 | import android.content.Context
6 | import android.content.res.AssetFileDescriptor
7 | import android.database.Cursor
8 | import android.net.Uri
9 | import android.os.Build
10 | import android.provider.MediaStore
11 | import github.leavesczy.matisse.MediaType
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.withContext
14 | import java.io.File
15 |
16 | /**
17 | * @Author: leavesCZY
18 | * @Date: 2022/6/2 11:11
19 | * @Desc:
20 | */
21 | internal object MediaProvider {
22 |
23 | data class MediaInfo(
24 | val uri: Uri,
25 | val mediaId: Long,
26 | val bucketId: String,
27 | val bucketName: String,
28 | val path: String,
29 | val name: String,
30 | val mimeType: String,
31 | val size: Long
32 | )
33 |
34 | suspend fun createImage(
35 | context: Context,
36 | imageName: String,
37 | mimeType: String
38 | ): Uri? {
39 | return withContext(context = Dispatchers.Default) {
40 | try {
41 | val contentValues = ContentValues()
42 | contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, imageName)
43 | contentValues.put(MediaStore.Images.Media.MIME_TYPE, mimeType)
44 | val imageCollection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
45 | MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
46 | } else {
47 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI
48 | }
49 | context.contentResolver.insert(imageCollection, contentValues)
50 | } catch (throwable: Throwable) {
51 | throwable.printStackTrace()
52 | null
53 | }
54 | }
55 | }
56 |
57 | suspend fun deleteMedia(context: Context, uri: Uri) {
58 | withContext(context = Dispatchers.Default) {
59 | try {
60 | context.contentResolver.delete(uri, null, null)
61 | } catch (throwable: Throwable) {
62 | throwable.printStackTrace()
63 | }
64 | }
65 | }
66 |
67 | private suspend fun loadResources(
68 | context: Context,
69 | selection: String?,
70 | selectionArgs: Array?
71 | ): List? {
72 | return withContext(context = Dispatchers.Default) {
73 | val idColumn = MediaStore.MediaColumns._ID
74 | val pathColumn = MediaStore.MediaColumns.DATA
75 | val sizeColumn = MediaStore.MediaColumns.SIZE
76 | val displayNameColumn = MediaStore.MediaColumns.DISPLAY_NAME
77 | val mineTypeColumn = MediaStore.MediaColumns.MIME_TYPE
78 | val bucketIdColumn = MediaStore.MediaColumns.BUCKET_ID
79 | val bucketDisplayNameColumn = MediaStore.MediaColumns.BUCKET_DISPLAY_NAME
80 | val dateModifiedColumn = MediaStore.MediaColumns.DATE_MODIFIED
81 | val projection = arrayOf(
82 | idColumn,
83 | pathColumn,
84 | sizeColumn,
85 | displayNameColumn,
86 | mineTypeColumn,
87 | bucketIdColumn,
88 | bucketDisplayNameColumn
89 | )
90 | val contentUri = MediaStore.Files.getContentUri("external")
91 | val sortOrder = "$dateModifiedColumn DESC"
92 | val mediaResourceList = mutableListOf()
93 | try {
94 | val cursor = context.contentResolver.query(
95 | contentUri,
96 | projection,
97 | selection,
98 | selectionArgs,
99 | sortOrder,
100 | ) ?: return@withContext null
101 | cursor.use { cursor ->
102 | while (cursor.moveToNext()) {
103 | try {
104 | val defaultId = Long.MAX_VALUE
105 | val id = cursor.getLong(idColumn, defaultId)
106 | val path = cursor.getString(pathColumn, "")
107 | if (id == defaultId || path.isBlank()) {
108 | continue
109 | }
110 | val file = File(path)
111 | if (!file.isFile || !file.exists()) {
112 | continue
113 | }
114 | val uri = ContentUris.withAppendedId(contentUri, id)
115 | val bucketId = cursor.getString(bucketIdColumn, "")
116 | val bucketName = cursor.getString(bucketDisplayNameColumn, "")
117 | val name = cursor.getString(displayNameColumn, "")
118 | val mimeType = cursor.getString(mineTypeColumn, "")
119 | val size = run {
120 | val cursorSize = cursor.getLong(sizeColumn, 0L)
121 | if (cursorSize <= 0L) {
122 | getFileRealSize(context = context, uri = uri) ?: 0L
123 | } else {
124 | cursorSize
125 | }
126 | }
127 | val mediaInfo = MediaInfo(
128 | uri = uri,
129 | mediaId = id,
130 | bucketId = bucketId,
131 | bucketName = bucketName,
132 | path = path,
133 | name = name,
134 | mimeType = mimeType,
135 | size = size
136 | )
137 | mediaResourceList.add(element = mediaInfo)
138 | } catch (throwable: Throwable) {
139 | throwable.printStackTrace()
140 | }
141 | }
142 | }
143 | } catch (throwable: Throwable) {
144 | throwable.printStackTrace()
145 | }
146 | mediaResourceList
147 | }
148 | }
149 |
150 | suspend fun getFileRealSize(context: Context, uri: Uri): Long? {
151 | return withContext(context = Dispatchers.Default) {
152 | try {
153 | context.contentResolver.openAssetFileDescriptor(uri, "r")?.use {
154 | val length = it.length
155 | if (length == AssetFileDescriptor.UNKNOWN_LENGTH) {
156 | null
157 | } else {
158 | length
159 | }
160 | }
161 | } catch (throwable: Exception) {
162 | throwable.printStackTrace()
163 | null
164 | }
165 | }
166 | }
167 |
168 | suspend fun loadResources(
169 | context: Context,
170 | mediaType: MediaType
171 | ): List? {
172 | return withContext(context = Dispatchers.Default) {
173 | loadResources(
174 | context = context,
175 | selection = generateSqlSelection(mediaType = mediaType),
176 | selectionArgs = null
177 | )
178 | }
179 | }
180 |
181 | private fun generateSqlSelection(mediaType: MediaType): String {
182 | val mediaTypeColumn = MediaStore.Files.FileColumns.MEDIA_TYPE
183 | val mediaTypeImageColumn = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
184 | val mediaTypeVideoColumn = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
185 | val mimeTypeColumn = MediaStore.Files.FileColumns.MIME_TYPE
186 | val queryImageSelection =
187 | "$mediaTypeColumn = $mediaTypeImageColumn and $mimeTypeColumn like 'image/%'"
188 | val queryVideoSelection =
189 | "$mediaTypeColumn = $mediaTypeVideoColumn and $mimeTypeColumn like 'video/%'"
190 | return when (mediaType) {
191 | is MediaType.ImageOnly -> {
192 | queryImageSelection
193 | }
194 |
195 | MediaType.VideoOnly -> {
196 | queryVideoSelection
197 | }
198 |
199 | is MediaType.ImageAndVideo -> {
200 | buildString {
201 | append(queryImageSelection)
202 | append(" or ")
203 | append(queryVideoSelection)
204 | }
205 | }
206 |
207 | is MediaType.MultipleMimeType -> {
208 | mediaType.mimeTypes.joinToString(
209 | prefix = "$mimeTypeColumn in (",
210 | postfix = ")",
211 | separator = ",",
212 | transform = {
213 | "'${it}'"
214 | }
215 | )
216 | }
217 | }
218 | }
219 |
220 | suspend fun loadResources(context: Context, uri: Uri): MediaInfo? {
221 | return withContext(context = Dispatchers.Default) {
222 | val id = ContentUris.parseId(uri)
223 | val selection = MediaStore.MediaColumns._ID + " = " + id
224 | val resources = loadResources(
225 | context = context,
226 | selection = selection,
227 | selectionArgs = null
228 | )
229 | if (resources.isNullOrEmpty() || resources.size != 1) {
230 | null
231 | } else {
232 | resources[0]
233 | }
234 | }
235 | }
236 |
237 | private fun Cursor.getLong(columnName: String, default: Long): Long {
238 | return try {
239 | val columnIndex = getColumnIndexOrThrow(columnName)
240 | getLong(columnIndex)
241 | } catch (throwable: IllegalArgumentException) {
242 | throwable.printStackTrace()
243 | default
244 | }
245 | }
246 |
247 | private fun Cursor.getString(columnName: String, default: String): String {
248 | return try {
249 | val columnIndex = getColumnIndexOrThrow(columnName)
250 | getString(columnIndex) ?: default
251 | } catch (throwable: IllegalArgumentException) {
252 | throwable.printStackTrace()
253 | default
254 | }
255 | }
256 |
257 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/ui/MatissePage.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.ui
2 |
3 | import androidx.compose.animation.animateColorAsState
4 | import androidx.compose.animation.core.Spring
5 | import androidx.compose.animation.core.VisibilityThreshold
6 | import androidx.compose.animation.core.spring
7 | import androidx.compose.foundation.background
8 | import androidx.compose.foundation.clickable
9 | import androidx.compose.foundation.layout.Arrangement
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.PaddingValues
12 | import androidx.compose.foundation.layout.Spacer
13 | import androidx.compose.foundation.layout.aspectRatio
14 | import androidx.compose.foundation.layout.fillMaxSize
15 | import androidx.compose.foundation.layout.padding
16 | import androidx.compose.foundation.lazy.grid.GridCells
17 | import androidx.compose.foundation.lazy.grid.LazyGridItemScope
18 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
19 | import androidx.compose.foundation.lazy.grid.items
20 | import androidx.compose.foundation.shape.CircleShape
21 | import androidx.compose.foundation.shape.RoundedCornerShape
22 | import androidx.compose.material.icons.Icons
23 | import androidx.compose.material.icons.filled.PhotoCamera
24 | import androidx.compose.material.icons.filled.PlayArrow
25 | import androidx.compose.material3.Icon
26 | import androidx.compose.material3.Scaffold
27 | import androidx.compose.runtime.Composable
28 | import androidx.compose.runtime.Stable
29 | import androidx.compose.runtime.getValue
30 | import androidx.compose.ui.Alignment
31 | import androidx.compose.ui.Modifier
32 | import androidx.compose.ui.draw.clip
33 | import androidx.compose.ui.draw.shadow
34 | import androidx.compose.ui.graphics.Color
35 | import androidx.compose.ui.res.colorResource
36 | import androidx.compose.ui.unit.IntOffset
37 | import androidx.compose.ui.unit.dp
38 | import github.leavesczy.matisse.ImageEngine
39 | import github.leavesczy.matisse.MediaResource
40 | import github.leavesczy.matisse.R
41 | import github.leavesczy.matisse.internal.logic.MatisseBottomBarViewState
42 | import github.leavesczy.matisse.internal.logic.MatisseMediaExtend
43 | import github.leavesczy.matisse.internal.logic.MatissePageViewState
44 |
45 | /**
46 | * @Author: leavesCZY
47 | * @Date: 2022/5/31 16:36
48 | * @Desc:
49 | */
50 | @Composable
51 | internal fun MatissePage(
52 | pageViewState: MatissePageViewState,
53 | bottomBarViewState: MatisseBottomBarViewState,
54 | onRequestTakePicture: () -> Unit,
55 | onClickSure: () -> Unit,
56 | selectMediaInFastSelectMode: (MediaResource) -> Unit
57 | ) {
58 | Scaffold(
59 | modifier = Modifier
60 | .fillMaxSize(),
61 | containerColor = colorResource(id = R.color.matisse_main_page_background_color),
62 | topBar = {
63 | MatisseTopBar(
64 | modifier = Modifier,
65 | title = pageViewState.selectedBucket.bucketName,
66 | mediaBucketsInfo = pageViewState.mediaBucketsInfo,
67 | onClickBucket = pageViewState.onClickBucket,
68 | imageEngine = pageViewState.imageEngine
69 | )
70 | },
71 | bottomBar = {
72 | if (!pageViewState.fastSelect) {
73 | MatisseBottomBar(
74 | modifier = Modifier,
75 | viewState = bottomBarViewState,
76 | onClickSure = onClickSure
77 | )
78 | }
79 | }
80 | ) { innerPadding ->
81 | LazyVerticalGrid(
82 | modifier = Modifier
83 | .fillMaxSize()
84 | .padding(paddingValues = innerPadding),
85 | state = pageViewState.lazyGridState,
86 | columns = GridCells.Fixed(count = pageViewState.gridColumns),
87 | horizontalArrangement = Arrangement.spacedBy(space = 1.dp),
88 | verticalArrangement = Arrangement.spacedBy(space = 1.dp),
89 | contentPadding = PaddingValues(
90 | top = 1.dp,
91 | bottom = if (pageViewState.fastSelect) {
92 | 16.dp
93 | } else {
94 | 1.dp
95 | }
96 | )
97 | ) {
98 | if (pageViewState.selectedBucket.supportCapture) {
99 | item(
100 | key = "CaptureItem",
101 | contentType = "CaptureItem"
102 | ) {
103 | CaptureItem(
104 | modifier = Modifier
105 | .matisseAnimateItem(lazyGridItemScope = this),
106 | onClick = onRequestTakePicture
107 | )
108 | }
109 | }
110 | items(
111 | items = pageViewState.selectedBucket.resources,
112 | key = {
113 | it.mediaId
114 | },
115 | contentType = {
116 | "MediaItem"
117 | }
118 | ) {
119 | if (pageViewState.fastSelect) {
120 | MediaItemFastSelect(
121 | modifier = Modifier
122 | .matisseAnimateItem(lazyGridItemScope = this),
123 | mediaResource = it.media,
124 | imageEngine = pageViewState.imageEngine,
125 | onClickMedia = selectMediaInFastSelectMode
126 | )
127 | } else {
128 | MediaItem(
129 | modifier = Modifier
130 | .matisseAnimateItem(lazyGridItemScope = this),
131 | mediaResource = it,
132 | imageEngine = pageViewState.imageEngine,
133 | onClickMedia = pageViewState.onClickMedia,
134 | onClickCheckBox = pageViewState.onMediaCheckChanged
135 | )
136 | }
137 | }
138 | }
139 | }
140 | }
141 |
142 | @Composable
143 | private fun CaptureItem(
144 | modifier: Modifier,
145 | onClick: () -> Unit
146 | ) {
147 | Box(
148 | modifier = modifier
149 | .aspectRatio(ratio = 1f)
150 | .clip(shape = RoundedCornerShape(size = 4.dp))
151 | .background(color = colorResource(id = R.color.matisse_capture_item_background_color))
152 | .clickable(onClick = onClick),
153 | contentAlignment = Alignment.Center
154 | ) {
155 | Icon(
156 | modifier = Modifier
157 | .fillMaxSize(fraction = 0.5f),
158 | imageVector = Icons.Filled.PhotoCamera,
159 | tint = colorResource(id = R.color.matisse_capture_item_icon_color),
160 | contentDescription = "Capture"
161 | )
162 | }
163 | }
164 |
165 | @Composable
166 | private fun MediaItem(
167 | modifier: Modifier,
168 | mediaResource: MatisseMediaExtend,
169 | imageEngine: ImageEngine,
170 | onClickMedia: (MatisseMediaExtend) -> Unit,
171 | onClickCheckBox: (MatisseMediaExtend) -> Unit
172 | ) {
173 | Box(
174 | modifier = modifier
175 | .aspectRatio(ratio = 1f)
176 | .clickable {
177 | onClickMedia(mediaResource)
178 | },
179 | contentAlignment = Alignment.Center
180 | ) {
181 | imageEngine.Thumbnail(mediaResource = mediaResource.media)
182 | if (mediaResource.media.isVideo) {
183 | VideoIcon(
184 | modifier = Modifier
185 | .fillMaxSize(fraction = 0.24f)
186 | )
187 | }
188 | val scrimColor by animateColorAsState(
189 | targetValue = if (mediaResource.selectState.value.isSelected) {
190 | colorResource(id = R.color.matisse_media_item_scrim_color_when_selected)
191 | } else {
192 | colorResource(id = R.color.matisse_media_item_scrim_color_when_unselected)
193 | }
194 | )
195 | Spacer(
196 | modifier = Modifier
197 | .fillMaxSize()
198 | .background(color = scrimColor)
199 | )
200 | Box(
201 | modifier = Modifier
202 | .align(alignment = Alignment.TopEnd)
203 | .fillMaxSize(fraction = 0.33f)
204 | .clickableNoRipple {
205 | onClickCheckBox(mediaResource)
206 | },
207 | contentAlignment = Alignment.Center
208 | ) {
209 | MatisseCheckbox(
210 | modifier = Modifier
211 | .fillMaxSize(fraction = 0.68f),
212 | selectState = mediaResource.selectState.value,
213 | onClick = {
214 | onClickCheckBox(mediaResource)
215 | }
216 | )
217 | }
218 | }
219 | }
220 |
221 | @Composable
222 | private fun MediaItemFastSelect(
223 | modifier: Modifier,
224 | mediaResource: MediaResource,
225 | imageEngine: ImageEngine,
226 | onClickMedia: (MediaResource) -> Unit
227 | ) {
228 | Box(
229 | modifier = modifier
230 | .aspectRatio(ratio = 1f)
231 | .clickable {
232 | onClickMedia(mediaResource)
233 | },
234 | contentAlignment = Alignment.Center
235 | ) {
236 | imageEngine.Thumbnail(mediaResource = mediaResource)
237 | if (mediaResource.isVideo) {
238 | VideoIcon(
239 | modifier = Modifier
240 | .fillMaxSize(fraction = 0.24f)
241 | )
242 | }
243 | }
244 | }
245 |
246 | @Composable
247 | internal fun VideoIcon(modifier: Modifier) {
248 | Box(
249 | modifier = modifier
250 | .shadow(elevation = 1.dp, shape = CircleShape)
251 | .clip(shape = CircleShape)
252 | .background(color = colorResource(id = R.color.matisse_video_icon_color)),
253 | contentAlignment = Alignment.Center
254 | ) {
255 | Icon(
256 | modifier = Modifier
257 | .fillMaxSize(fraction = 0.62f),
258 | imageVector = Icons.Filled.PlayArrow,
259 | tint = Color.Black,
260 | contentDescription = null
261 | )
262 | }
263 | }
264 |
265 | @Stable
266 | private fun Modifier.matisseAnimateItem(lazyGridItemScope: LazyGridItemScope): Modifier {
267 | return with(receiver = lazyGridItemScope) {
268 | animateItem(
269 | fadeInSpec = spring(stiffness = Spring.StiffnessMedium),
270 | fadeOutSpec = spring(stiffness = Spring.StiffnessMedium),
271 | placementSpec = spring(
272 | stiffness = Spring.StiffnessMediumLow,
273 | visibilityThreshold = IntOffset.VisibilityThreshold
274 | )
275 | )
276 | }
277 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2025 leavesCZY
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/app/src/main/java/github/leavesczy/matisse/samples/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.samples
2 |
3 | import android.os.Bundle
4 | import androidx.activity.compose.rememberLauncherForActivityResult
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.viewModels
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.compose.foundation.background
9 | import androidx.compose.foundation.layout.Arrangement
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.Column
12 | import androidx.compose.foundation.layout.FlowRow
13 | import androidx.compose.foundation.layout.Row
14 | import androidx.compose.foundation.layout.WindowInsets
15 | import androidx.compose.foundation.layout.fillMaxSize
16 | import androidx.compose.foundation.layout.fillMaxWidth
17 | import androidx.compose.foundation.layout.height
18 | import androidx.compose.foundation.layout.navigationBarsPadding
19 | import androidx.compose.foundation.layout.padding
20 | import androidx.compose.foundation.layout.size
21 | import androidx.compose.foundation.layout.statusBarsPadding
22 | import androidx.compose.foundation.rememberScrollState
23 | import androidx.compose.foundation.shape.RoundedCornerShape
24 | import androidx.compose.foundation.verticalScroll
25 | import androidx.compose.material3.Button
26 | import androidx.compose.material3.Card
27 | import androidx.compose.material3.Checkbox
28 | import androidx.compose.material3.HorizontalDivider
29 | import androidx.compose.material3.MaterialTheme
30 | import androidx.compose.material3.RadioButton
31 | import androidx.compose.material3.Scaffold
32 | import androidx.compose.material3.Text
33 | import androidx.compose.runtime.Composable
34 | import androidx.compose.ui.Alignment
35 | import androidx.compose.ui.Modifier
36 | import androidx.compose.ui.draw.clip
37 | import androidx.compose.ui.graphics.Color
38 | import androidx.compose.ui.layout.ContentScale
39 | import androidx.compose.ui.res.stringResource
40 | import androidx.compose.ui.unit.dp
41 | import androidx.compose.ui.unit.sp
42 | import androidx.core.view.WindowCompat
43 | import androidx.core.view.WindowInsetsControllerCompat
44 | import coil3.compose.AsyncImage
45 | import github.leavesczy.matisse.MatisseCaptureContract
46 | import github.leavesczy.matisse.MatisseContract
47 | import github.leavesczy.matisse.MediaResource
48 | import github.leavesczy.matisse.MediaType
49 | import github.leavesczy.matisse.samples.logic.MainPageViewState
50 | import github.leavesczy.matisse.samples.logic.MainViewModel
51 | import github.leavesczy.matisse.samples.logic.MediaCaptureStrategy
52 | import github.leavesczy.matisse.samples.logic.MediaFilterStrategy
53 | import github.leavesczy.matisse.samples.logic.MediaImageEngine
54 | import github.leavesczy.matisse.samples.theme.MatisseTheme
55 |
56 | /**
57 | * @Author: leavesCZY
58 | * @Date: 2024/2/21 12:01
59 | * @Desc:
60 | */
61 | class MainActivity : AppCompatActivity() {
62 |
63 | private val mainViewModel by viewModels()
64 |
65 | override fun onCreate(savedInstanceState: Bundle?) {
66 | setSystemBarUi()
67 | super.onCreate(savedInstanceState)
68 | setContent {
69 | val takePictureLauncher =
70 | rememberLauncherForActivityResult(contract = MatisseCaptureContract()) {
71 | mainViewModel.takePictureResult(result = it)
72 | }
73 | val mediaPickerLauncher =
74 | rememberLauncherForActivityResult(contract = MatisseContract()) {
75 | mainViewModel.mediaPickerResult(result = it)
76 | }
77 | MatisseTheme {
78 | MainPage(
79 | pageViewState = mainViewModel.pageViewState,
80 | imageAndVideo = {
81 | mediaPickerLauncher.launch(
82 | mainViewModel.buildMatisse(mediaType = MediaType.ImageAndVideo)
83 | )
84 | },
85 | imageOnly = {
86 | mediaPickerLauncher.launch(
87 | mainViewModel.buildMatisse(mediaType = MediaType.ImageOnly)
88 | )
89 | },
90 | videoOnly = {
91 | mediaPickerLauncher.launch(mainViewModel.buildMatisse(mediaType = MediaType.VideoOnly))
92 | },
93 | gifAndMp4 = {
94 | mediaPickerLauncher.launch(
95 | mainViewModel.buildMatisse(
96 | mediaType = MediaType.MultipleMimeType(
97 | mimeTypes = setOf("image/gif", "video/mp4")
98 | )
99 | )
100 | )
101 | },
102 | takePicture = {
103 | val matisseCapture = mainViewModel.buildMediaCaptureStrategy()
104 | if (matisseCapture != null) {
105 | takePictureLauncher.launch(matisseCapture)
106 | }
107 | }
108 | )
109 | }
110 | }
111 | }
112 |
113 | private fun setSystemBarUi() {
114 | WindowCompat.setDecorFitsSystemWindows(window, false)
115 | WindowInsetsControllerCompat(window, window.decorView).apply {
116 | isAppearanceLightStatusBars = false
117 | isAppearanceLightNavigationBars = false
118 | }
119 | }
120 |
121 | }
122 |
123 | @Composable
124 | private fun MainPage(
125 | pageViewState: MainPageViewState,
126 | imageAndVideo: () -> Unit,
127 | imageOnly: () -> Unit,
128 | videoOnly: () -> Unit,
129 | gifAndMp4: () -> Unit,
130 | takePicture: () -> Unit
131 | ) {
132 | Scaffold(
133 | modifier = Modifier
134 | .fillMaxSize()
135 | .background(color = MaterialTheme.colorScheme.background),
136 | contentWindowInsets = WindowInsets(left = 0.dp, right = 0.dp, top = 0.dp, bottom = 0.dp),
137 | topBar = {
138 | Box(
139 | modifier = Modifier
140 | .background(color = MaterialTheme.colorScheme.primary)
141 | .fillMaxWidth()
142 | .statusBarsPadding()
143 | .height(height = 55.dp)
144 | ) {
145 | Text(
146 | modifier = Modifier
147 | .align(alignment = Alignment.CenterStart)
148 | .padding(horizontal = 10.dp),
149 | text = stringResource(id = R.string.app_name),
150 | fontSize = 22.sp,
151 | color = Color.White
152 | )
153 | }
154 | }
155 | ) { innerPadding ->
156 | Column(
157 | modifier = Modifier
158 | .padding(paddingValues = innerPadding)
159 | .navigationBarsPadding()
160 | .verticalScroll(state = rememberScrollState())
161 | .padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 50.dp),
162 | horizontalAlignment = Alignment.Start,
163 | ) {
164 | Title(text = "gridColumns")
165 | FlowRow(
166 | modifier = Modifier
167 | .fillMaxWidth(),
168 | verticalArrangement = Arrangement.Center
169 | ) {
170 | for (gridColumns in 2..5) {
171 | RadioButton(
172 | tips = gridColumns.toString(),
173 | selected = pageViewState.gridColumns == gridColumns,
174 | onClick = {
175 | pageViewState.onGridColumnsChanged(gridColumns)
176 | }
177 | )
178 | }
179 | }
180 | Title(text = "maxSelectable")
181 | FlowRow(
182 | modifier = Modifier
183 | .fillMaxWidth(),
184 | verticalArrangement = Arrangement.Center
185 | ) {
186 | for (maxSelectable in 1..4) {
187 | RadioButton(
188 | tips = maxSelectable.toString(),
189 | selected = pageViewState.maxSelectable == maxSelectable,
190 | onClick = {
191 | pageViewState.onMaxSelectableChanged(maxSelectable)
192 | }
193 | )
194 | }
195 | }
196 | OptionDivider()
197 | Row(
198 | modifier = Modifier
199 | .fillMaxWidth(),
200 | verticalAlignment = Alignment.CenterVertically
201 | ) {
202 | Title(text = "fastSelect")
203 | Checkbox(
204 | checked = pageViewState.fastSelect,
205 | onCheckedChange = pageViewState.onFastSelectChanged
206 | )
207 | }
208 | OptionDivider()
209 | Title(text = "ImageEngine")
210 | FlowRow(
211 | modifier = Modifier
212 | .fillMaxWidth()
213 | ) {
214 | for (engine in MediaImageEngine.entries) {
215 | RadioButton(
216 | tips = engine.name,
217 | selected = pageViewState.imageEngine == engine,
218 | onClick = {
219 | pageViewState.onImageEngineChanged(engine)
220 | }
221 | )
222 | }
223 | }
224 | OptionDivider()
225 | Row(
226 | modifier = Modifier
227 | .fillMaxWidth(),
228 | verticalAlignment = Alignment.CenterVertically
229 | ) {
230 | Title(text = "singleMediaType")
231 | Checkbox(
232 | checked = pageViewState.singleMediaType,
233 | onCheckedChange = pageViewState.onSingleMediaTypeChanged
234 | )
235 | }
236 | OptionDivider()
237 | Title(text = "mediaFilter")
238 | FlowRow(
239 | modifier = Modifier
240 | .fillMaxWidth()
241 | ) {
242 | for (strategy in MediaFilterStrategy.entries) {
243 | RadioButton(
244 | tips = strategy.name,
245 | selected = pageViewState.filterStrategy == strategy,
246 | onClick = {
247 | pageViewState.onFilterStrategyChanged(strategy)
248 | }
249 | )
250 | }
251 | }
252 | OptionDivider()
253 | Title(text = "CaptureStrategy")
254 | FlowRow(
255 | modifier = Modifier
256 | .fillMaxWidth(),
257 | verticalArrangement = Arrangement.Center
258 | ) {
259 | for (strategy in MediaCaptureStrategy.entries) {
260 | RadioButton(
261 | tips = strategy.name,
262 | selected = pageViewState.captureStrategy == strategy,
263 | onClick = {
264 | pageViewState.onCaptureStrategyChanged(strategy)
265 | }
266 | )
267 | }
268 | }
269 | OptionDivider()
270 | Row(
271 | modifier = Modifier
272 | .fillMaxWidth(),
273 | verticalAlignment = Alignment.CenterVertically
274 | ) {
275 | Title(text = "CapturePreferencesCustom")
276 | Checkbox(
277 | checked = pageViewState.capturePreferencesCustom,
278 | enabled = pageViewState.captureStrategy != MediaCaptureStrategy.Close,
279 | onCheckedChange = pageViewState.onCapturePreferencesCustomChanged
280 | )
281 | }
282 | Button(
283 | text = "图片 + 视频",
284 | onClick = imageAndVideo
285 | )
286 | Button(
287 | text = "图片",
288 | onClick = imageOnly
289 | )
290 | Button(
291 | text = "视频",
292 | onClick = videoOnly
293 | )
294 | Button(
295 | text = "gif + mp4",
296 | onClick = gifAndMp4
297 | )
298 | Button(
299 | text = "直接拍照",
300 | enabled = pageViewState.captureStrategy != MediaCaptureStrategy.Close,
301 | onClick = takePicture
302 | )
303 | Button(
304 | text = "切换主题",
305 | onClick = pageViewState.switchTheme
306 | )
307 | for (mediaResource in pageViewState.mediaList) {
308 | MediaResourceItem(mediaResource = mediaResource)
309 | }
310 | }
311 | }
312 | }
313 |
314 | @Composable
315 | private fun OptionDivider() {
316 | HorizontalDivider(
317 | modifier = Modifier
318 | .fillMaxWidth()
319 | .padding(bottom = 6.dp),
320 | thickness = 0.5.dp
321 | )
322 | }
323 |
324 | @Composable
325 | private fun Button(
326 | text: String,
327 | enabled: Boolean = true,
328 | onClick: () -> Unit
329 | ) {
330 | Button(
331 | modifier = Modifier
332 | .fillMaxWidth(),
333 | enabled = enabled,
334 | onClick = onClick
335 | ) {
336 | Text(
337 | modifier = Modifier,
338 | text = text,
339 | fontSize = 16.sp
340 | )
341 | }
342 | }
343 |
344 | @Composable
345 | private fun Title(text: String) {
346 | Text(
347 | modifier = Modifier,
348 | text = text,
349 | fontSize = 17.sp
350 | )
351 | }
352 |
353 | @Composable
354 | private fun RadioButton(
355 | tips: String,
356 | selected: Boolean,
357 | onClick: () -> Unit
358 | ) {
359 | Row(
360 | modifier = Modifier,
361 | verticalAlignment = Alignment.CenterVertically
362 | ) {
363 | Text(
364 | modifier = Modifier,
365 | text = tips,
366 | fontSize = 16.sp
367 | )
368 | RadioButton(
369 | modifier = Modifier,
370 | selected = selected,
371 | onClick = onClick
372 | )
373 | }
374 | }
375 |
376 | @Composable
377 | private fun MediaResourceItem(mediaResource: MediaResource) {
378 | Card(
379 | modifier = Modifier
380 | .fillMaxWidth()
381 | .padding(vertical = 6.dp),
382 | shape = RoundedCornerShape(size = 12.dp)
383 | ) {
384 | Row(
385 | modifier = Modifier
386 | .padding(horizontal = 8.dp, vertical = 10.dp)
387 | .fillMaxWidth(),
388 | horizontalArrangement = Arrangement.Start,
389 | verticalAlignment = Alignment.CenterVertically
390 | ) {
391 | AsyncImage(
392 | modifier = Modifier
393 | .size(size = 80.dp)
394 | .clip(shape = RoundedCornerShape(size = 12.dp)),
395 | model = mediaResource.uri,
396 | contentScale = ContentScale.Crop,
397 | contentDescription = null
398 | )
399 | Text(
400 | modifier = Modifier
401 | .weight(weight = 1f)
402 | .padding(start = 10.dp),
403 | text = mediaResource.toString(),
404 | fontSize = 15.sp,
405 | lineHeight = 17.sp
406 | )
407 | }
408 | }
409 | }
--------------------------------------------------------------------------------
/matisse/src/main/java/github/leavesczy/matisse/internal/logic/MatisseViewModel.kt:
--------------------------------------------------------------------------------
1 | package github.leavesczy.matisse.internal.logic
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import android.widget.Toast
6 | import androidx.annotation.StringRes
7 | import androidx.compose.foundation.lazy.grid.LazyGridState
8 | import androidx.compose.runtime.MutableState
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.setValue
12 | import androidx.lifecycle.AndroidViewModel
13 | import androidx.lifecycle.viewModelScope
14 | import github.leavesczy.matisse.Matisse
15 | import github.leavesczy.matisse.MediaResource
16 | import github.leavesczy.matisse.R
17 | import kotlinx.coroutines.Dispatchers
18 | import kotlinx.coroutines.delay
19 | import kotlinx.coroutines.launch
20 | import kotlinx.coroutines.withContext
21 |
22 | /**
23 | * @Author: leavesCZY
24 | * @Date: 2022/6/1 19:19
25 | * @Desc:
26 | */
27 | internal class MatisseViewModel(application: Application, matisse: Matisse) :
28 | AndroidViewModel(application) {
29 |
30 | private val context: Context
31 | get() = getApplication()
32 |
33 | val maxSelectable = matisse.maxSelectable
34 |
35 | private val imageEngine = matisse.imageEngine
36 |
37 | private val gridColumns = matisse.gridColumns
38 |
39 | private val fastSelect = matisse.fastSelect
40 |
41 | val mediaType = matisse.mediaType
42 |
43 | val singleMediaType = matisse.singleMediaType
44 |
45 | val captureStrategy = matisse.captureStrategy
46 |
47 | private val mediaFilter = matisse.mediaFilter
48 |
49 | private val defaultBucketId = "&__matisseDefaultBucketId__&"
50 |
51 | private val defaultBucket = MatisseMediaBucket(
52 | bucketId = defaultBucketId,
53 | bucketName = getString(id = R.string.matisse_default_bucket_name),
54 | supportCapture = captureStrategy != null,
55 | resources = emptyList()
56 | )
57 |
58 | private val allMediaResources = mutableListOf()
59 |
60 | var pageViewState by mutableStateOf(
61 | value = MatissePageViewState(
62 | maxSelectable = maxSelectable,
63 | fastSelect = fastSelect,
64 | gridColumns = gridColumns,
65 | imageEngine = imageEngine,
66 | captureStrategy = captureStrategy,
67 | selectedBucket = defaultBucket,
68 | mediaBucketsInfo = emptyList(),
69 | onClickBucket = ::onClickBucket,
70 | lazyGridState = LazyGridState(),
71 | onClickMedia = ::onClickMedia,
72 | onMediaCheckChanged = ::onMediaCheckChanged
73 | )
74 | )
75 | private set
76 |
77 | var bottomBarViewState by mutableStateOf(
78 | value = buildBottomBarViewState()
79 | )
80 | private set
81 |
82 | var previewPageViewState by mutableStateOf(
83 | value = MatissePreviewPageViewState(
84 | visible = false,
85 | initialPage = 0,
86 | maxSelectable = maxSelectable,
87 | sureButtonText = "",
88 | sureButtonClickable = false,
89 | previewResources = emptyList(),
90 | onMediaCheckChanged = {},
91 | onDismissRequest = {}
92 | )
93 | )
94 | private set
95 |
96 | var loadingDialogVisible by mutableStateOf(value = false)
97 | private set
98 |
99 | private val unselectedDisabledMediaSelectState = MatisseMediaSelectState(
100 | isSelected = false,
101 | isEnabled = false,
102 | positionIndex = -1
103 | )
104 |
105 | private val unselectedEnabledMediaSelectState = MatisseMediaSelectState(
106 | isSelected = false,
107 | isEnabled = true,
108 | positionIndex = -1
109 | )
110 |
111 | fun requestReadMediaPermissionResult(granted: Boolean) {
112 | viewModelScope.launch(context = Dispatchers.Main.immediate) {
113 | showLoadingDialog()
114 | dismissPreviewPage()
115 | allMediaResources.clear()
116 | if (granted) {
117 | val allResources = loadMediaResources()
118 | allMediaResources.addAll(elements = allResources)
119 | val collectBucket = defaultBucket.copy(resources = allResources)
120 | val allMediaBuckets = buildList {
121 | add(
122 | element = MatisseMediaBucketInfo(
123 | bucketId = collectBucket.bucketId,
124 | bucketName = collectBucket.bucketName,
125 | size = collectBucket.resources.size,
126 | firstMedia = collectBucket.resources.firstOrNull()?.media
127 | )
128 | )
129 | addAll(
130 | elements = allResources.groupBy {
131 | it.bucketId
132 | }.mapNotNull {
133 | val bucketId = it.key
134 | val resources = it.value
135 | val firstResource = resources.firstOrNull()
136 | val bucketName = firstResource?.bucketName
137 | if (bucketName.isNullOrBlank()) {
138 | null
139 | } else {
140 | MatisseMediaBucketInfo(
141 | bucketId = bucketId,
142 | bucketName = bucketName,
143 | size = resources.size,
144 | firstMedia = firstResource.media
145 | )
146 | }
147 | }
148 | )
149 | }
150 | pageViewState = pageViewState.copy(
151 | mediaBucketsInfo = allMediaBuckets,
152 | selectedBucket = collectBucket
153 | )
154 | defaultSelectedResources(allMediaResources = allResources)
155 | } else {
156 | resetViewState()
157 | showToast(id = R.string.matisse_read_media_permission_denied)
158 | }
159 | bottomBarViewState = buildBottomBarViewState()
160 | dismissLoadingDialog()
161 | }
162 | }
163 |
164 | private suspend fun defaultSelectedResources(allMediaResources: List) {
165 | val defaultSelectedMediaIds = if (mediaFilter == null || fastSelect) {
166 | emptyList()
167 | } else {
168 | allMediaResources.filter {
169 | mediaFilter.selectMedia(mediaResource = it.media)
170 | }.map {
171 | it.mediaId
172 | }
173 | }
174 | if (defaultSelectedMediaIds.isNotEmpty()) {
175 | var positionIndex = 0
176 | allMediaResources.forEach { media ->
177 | val selectState = media.selectState as MutableState
178 | if (defaultSelectedMediaIds.contains(element = media.mediaId)) {
179 | selectState.value = MatisseMediaSelectState(
180 | isSelected = true,
181 | isEnabled = true,
182 | positionIndex = positionIndex
183 | )
184 | positionIndex++
185 | } else {
186 | selectState.value = if (defaultSelectedMediaIds.size >= maxSelectable) {
187 | unselectedDisabledMediaSelectState
188 | } else {
189 | unselectedEnabledMediaSelectState
190 | }
191 | }
192 | }
193 | }
194 | }
195 |
196 | private suspend fun loadMediaResources(): List {
197 | return withContext(context = Dispatchers.Default) {
198 | val resourcesInfo = MediaProvider.loadResources(
199 | context = context,
200 | mediaType = mediaType
201 | )
202 | if (resourcesInfo.isNullOrEmpty()) {
203 | emptyList()
204 | } else {
205 | resourcesInfo.mapNotNull {
206 | val media = MediaResource(
207 | uri = it.uri,
208 | path = it.path,
209 | name = it.name,
210 | mimeType = it.mimeType,
211 | size = it.size
212 | )
213 | if (mediaFilter?.ignoreMedia(mediaResource = media) == true) {
214 | null
215 | } else {
216 | MatisseMediaExtend(
217 | mediaId = it.mediaId,
218 | bucketId = it.bucketId,
219 | bucketName = it.bucketName,
220 | media = media,
221 | selectState = mutableStateOf(value = unselectedEnabledMediaSelectState)
222 | )
223 | }
224 | }
225 | }
226 | }
227 | }
228 |
229 | private fun resetViewState() {
230 | pageViewState = pageViewState.copy(
231 | selectedBucket = defaultBucket,
232 | mediaBucketsInfo = listOf(
233 | element = MatisseMediaBucketInfo(
234 | bucketId = defaultBucket.bucketId,
235 | bucketName = defaultBucket.bucketName,
236 | size = defaultBucket.resources.size,
237 | firstMedia = defaultBucket.resources.firstOrNull()?.media,
238 | )
239 | )
240 | )
241 | }
242 |
243 | private suspend fun onClickBucket(bucketId: String) {
244 | val viewState = pageViewState
245 | val isDefaultBucketId = bucketId == defaultBucketId
246 | val bucketName = viewState.mediaBucketsInfo.first {
247 | it.bucketId == bucketId
248 | }.bucketName
249 | val supportCapture = isDefaultBucketId && defaultBucket.supportCapture
250 | val resources = if (isDefaultBucketId) {
251 | allMediaResources
252 | } else {
253 | allMediaResources.filter {
254 | it.bucketId == bucketId
255 | }
256 | }
257 | pageViewState = viewState.copy(
258 | selectedBucket = MatisseMediaBucket(
259 | bucketId = bucketId,
260 | bucketName = bucketName,
261 | supportCapture = supportCapture,
262 | resources = resources
263 | )
264 | )
265 | delay(timeMillis = 80)
266 | pageViewState.lazyGridState.animateScrollToItem(index = 0)
267 | }
268 |
269 | private fun onMediaCheckChanged(mediaResource: MatisseMediaExtend) {
270 | val selectState = mediaResource.selectState as MutableState
271 | if (selectState.value.isSelected) {
272 | selectState.value = unselectedEnabledMediaSelectState
273 | } else {
274 | if (maxSelectable == 1) {
275 | resetAllMediaSelectState(state = unselectedEnabledMediaSelectState)
276 | } else {
277 | val selectedResources = filterSelectedMediaResource()
278 | if (selectedResources.size >= maxSelectable) {
279 | showToast(text = textWhenTheQuantityExceedsTheLimit())
280 | return
281 | } else if (singleMediaType) {
282 | val illegalMediaType = selectedResources.any {
283 | it.media.isImage != mediaResource.media.isImage
284 | }
285 | if (illegalMediaType) {
286 | showToast(id = R.string.matisse_cannot_select_both_picture_and_video_at_the_same_time)
287 | return
288 | }
289 | }
290 | }
291 | selectState.value = MatisseMediaSelectState(
292 | isSelected = true,
293 | isEnabled = true,
294 | positionIndex = filterSelectedMediaResource().size
295 | )
296 | }
297 | rearrangeMediaPosition()
298 | updatePreviewPageIfNeed()
299 | bottomBarViewState = buildBottomBarViewState()
300 | }
301 |
302 | private fun rearrangeMediaPosition() {
303 | val selectedMedia = filterSelectedMediaResource()
304 | resetAllMediaSelectState(
305 | state = if (selectedMedia.size >= maxSelectable) {
306 | unselectedDisabledMediaSelectState
307 | } else {
308 | unselectedEnabledMediaSelectState
309 | }
310 | )
311 | selectedMedia.forEachIndexed { index, media ->
312 | val selectState = media.selectState as MutableState
313 | selectState.value = MatisseMediaSelectState(
314 | isSelected = true,
315 | isEnabled = true,
316 | positionIndex = index
317 | )
318 | }
319 | }
320 |
321 | private fun updatePreviewPageIfNeed() {
322 | val viewState = previewPageViewState
323 | if (viewState.visible) {
324 | val selectedResources = filterSelectedMediaResource()
325 | previewPageViewState = viewState.copy(
326 | sureButtonText = getString(
327 | id = R.string.matisse_sure,
328 | selectedResources.size,
329 | maxSelectable
330 | ),
331 | sureButtonClickable = selectedResources.isNotEmpty() && selectedResources.size <= maxSelectable
332 | )
333 | }
334 | }
335 |
336 | private fun textWhenTheQuantityExceedsTheLimit(): String {
337 | val includeImage = mediaType.includeImage
338 | val includeVideo = mediaType.includeVideo
339 | val stringId = if (includeImage && !includeVideo) {
340 | R.string.matisse_limit_the_number_of_image
341 | } else if (!includeImage && includeVideo) {
342 | R.string.matisse_limit_the_number_of_video
343 | } else {
344 | R.string.matisse_limit_the_number_of_media
345 | }
346 | return getString(
347 | id = stringId,
348 | maxSelectable
349 | )
350 | }
351 |
352 | private fun buildBottomBarViewState(): MatisseBottomBarViewState {
353 | val selected = filterSelectedMediaResource()
354 | val selectedResourcesIsNotEmpty = selected.isNotEmpty()
355 | return MatisseBottomBarViewState(
356 | previewButtonText = getString(id = R.string.matisse_preview),
357 | previewButtonClickable = selectedResourcesIsNotEmpty,
358 | onClickPreviewButton = ::onClickPreviewButton,
359 | sureButtonText = getString(
360 | id = R.string.matisse_sure,
361 | selected.size,
362 | maxSelectable
363 | ),
364 | sureButtonClickable = selectedResourcesIsNotEmpty && selected.size <= maxSelectable
365 | )
366 | }
367 |
368 | private fun onClickMedia(mediaResource: MatisseMediaExtend) {
369 | val totalResources = pageViewState.selectedBucket.resources
370 | previewResource(
371 | initialPage = totalResources.indexOf(element = mediaResource),
372 | totalResources = totalResources,
373 | selectedResources = filterSelectedMediaResource()
374 | )
375 | }
376 |
377 | private fun onClickPreviewButton() {
378 | val selected = filterSelectedMediaResource()
379 | previewResource(
380 | initialPage = 0,
381 | totalResources = selected,
382 | selectedResources = selected
383 | )
384 | }
385 |
386 | private fun previewResource(
387 | initialPage: Int,
388 | totalResources: List,
389 | selectedResources: List
390 | ) {
391 | previewPageViewState = previewPageViewState.copy(
392 | visible = true,
393 | initialPage = initialPage,
394 | sureButtonText = getString(
395 | id = R.string.matisse_sure,
396 | selectedResources.size,
397 | maxSelectable
398 | ),
399 | sureButtonClickable = selectedResources.isNotEmpty() && selectedResources.size <= maxSelectable,
400 | previewResources = totalResources,
401 | onMediaCheckChanged = ::onMediaCheckChanged,
402 | onDismissRequest = ::dismissPreviewPage
403 | )
404 | }
405 |
406 | private fun dismissPreviewPage() {
407 | val viewState = previewPageViewState
408 | if (viewState.visible) {
409 | previewPageViewState = viewState.copy(
410 | visible = false,
411 | onMediaCheckChanged = {},
412 | onDismissRequest = {}
413 | )
414 | }
415 | }
416 |
417 | fun filterSelectedMedia(): List {
418 | return filterSelectedMediaResource().map {
419 | it.media
420 | }
421 | }
422 |
423 | private fun filterSelectedMediaResource(): List {
424 | return allMediaResources.filter {
425 | it.selectState.value.isSelected
426 | }.sortedBy {
427 | it.selectState.value.positionIndex
428 | }
429 | }
430 |
431 | private fun resetAllMediaSelectState(state: MatisseMediaSelectState) {
432 | allMediaResources.forEach {
433 | (it.selectState as MutableState).value = state
434 | }
435 | }
436 |
437 | private fun showLoadingDialog() {
438 | loadingDialogVisible = true
439 | }
440 |
441 | private fun dismissLoadingDialog() {
442 | loadingDialogVisible = false
443 | }
444 |
445 | private fun getString(@StringRes id: Int): String {
446 | return context.getString(id)
447 | }
448 |
449 | private fun getString(@StringRes id: Int, vararg formatArgs: Any): String {
450 | return context.getString(id, *formatArgs)
451 | }
452 |
453 | private fun showToast(@StringRes id: Int) {
454 | showToast(text = getString(id = id))
455 | }
456 |
457 | private fun showToast(text: String) {
458 | if (text.isNotBlank()) {
459 | Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
460 | }
461 | }
462 |
463 | }
--------------------------------------------------------------------------------