├── 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 | | ![](https://github.com/leavesCZY/Matisse/assets/30774063/f2a0f801-d450-4c2c-81f8-07f71d6f6fd6) | ![](https://github.com/leavesCZY/Matisse/assets/30774063/7960c579-6ca7-4a63-bce7-f81d182e1df3) | ![](https://github.com/leavesCZY/Matisse/assets/30774063/d5a8b2cd-63fb-4c36-ad45-0659f4154bc5) | -------------------------------------------------------------------------------- /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 | } --------------------------------------------------------------------------------