diff --git a/VisualNovel/.gitignore b/VisualNovel/.gitignore new file mode 100644 index 0000000..3cb54ba --- /dev/null +++ b/VisualNovel/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +*/build/ +**/build/ +/captures +.externalNativeBuild +.cxx +local.properties \ No newline at end of file diff --git a/VisualNovel/.idea/.gitignore b/VisualNovel/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/VisualNovel/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/VisualNovel/.idea/codeStyles/Project.xml b/VisualNovel/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/VisualNovel/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/codeStyles/codeStyleConfig.xml b/VisualNovel/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/VisualNovel/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/compiler.xml b/VisualNovel/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/VisualNovel/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/deploymentTargetSelector.xml b/VisualNovel/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/VisualNovel/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/deviceManager.xml b/VisualNovel/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/VisualNovel/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/gradle.xml b/VisualNovel/.idea/gradle.xml new file mode 100644 index 0000000..8773fa4 --- /dev/null +++ b/VisualNovel/.idea/gradle.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/kotlinc.xml b/VisualNovel/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/VisualNovel/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/migrations.xml b/VisualNovel/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/VisualNovel/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/misc.xml b/VisualNovel/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/VisualNovel/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/runConfigurations.xml b/VisualNovel/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/VisualNovel/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/vcs.xml b/VisualNovel/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/VisualNovel/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/.gitignore b/VisualNovel/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/VisualNovel/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/VisualNovel/app/build.gradle.kts b/VisualNovel/app/build.gradle.kts new file mode 100644 index 0000000..9a66c87 --- /dev/null +++ b/VisualNovel/app/build.gradle.kts @@ -0,0 +1,283 @@ +import com.android.build.api.dsl.ApplicationProductFlavor + +plugins { + id("com.android.application") + kotlin("android") + id("kotlin-parcelize") + kotlin("kapt") + id("dagger.hilt.android.plugin") + //TODO - enable later: id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") + +} + +android { + namespace = Version.applicationId + compileSdk = 36 + + + defaultConfig { + applicationId = Version.applicationId + minSdk = Version.minSdk + targetSdk = Version.targetSdk + compileSdk = Version.targetSdk + versionCode = Version.versionCode + versionName = Version.versionName + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + setProperty("archivesBaseName", "${Version.applicationId}-build${Version.versionCode}") + multiDexEnabled = true + ndk { + abiFilters += listOf("armeabi", "armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + renderscriptTargetApi = 23 + renderscriptSupportModeEnabled = true + } + + kapt { + correctErrorTypes = true + arguments { + arg("AROUTER_MODULE_NAME", project.name) + } + } + + + + hilt { + enableExperimentalClasspathAggregation = true + enableAggregatingTask = false + } + + buildFeatures { + dataBinding = true + viewBinding = true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + signingConfigs { + val storeFileName = "storeFile" + val storePasswordName = "KEYSTORE_PWD" + val keyAliasName = "KEY_ALIAS" + val keyPasswordName = "KEY_PWD" + + fun names(suffix: String = "") = + listOf(storeFileName + suffix, storePasswordName + suffix, keyAliasName + suffix, keyPasswordName + suffix) + + create("release") { + storeFile = file(project.properties[names()[0]]?.toString() ?: "") + storePassword = project.properties[names()[1]] as? String? + keyAlias = project.properties[names()[2]] as? String? + keyPassword = project.properties[names()[3]] as? String? + } + getByName("debug") { + storeFile = file(project.properties[names()[0]]?.toString() ?: "") + storePassword = project.properties[names()[1]] as? String? + keyAlias = project.properties[names()[2]] as? String? + keyPassword = project.properties[names()[3]] as? String? + } + } + + + + buildTypes { + release { + isMinifyEnabled = false + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isMinifyEnabled = false + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + targetCompatibility(JavaVersion.VERSION_17) + sourceCompatibility(JavaVersion.VERSION_17) + } + + + + val flavorDimensionName = "VisualNovel" + flavorDimensions.add(flavorDimensionName) + productFlavors { + fun ApplicationProductFlavor.buildConfigString(name: String, value: String) = + buildConfigField("String", name, "\"$value\"") + + fun ApplicationProductFlavor.buildConfigBoolean(name: String, value: String) = + buildConfigField("Boolean", name, value) + + create("novelTest") { + dimension = flavorDimensionName + signingConfig = signingConfigs.getByName("debug") + + buildConfigString("HOST", "https://www.xxxxx.ai/") + buildConfigString("ABOUT_US", "https://www.xxxxx.ai/about") + buildConfigString("API_FROG", "https://www.test-frog.xxxxx.ai") + buildConfigString("EPAL_TERMS_SERVICES", "https://www.xxxxx.ai/policy/tos") + } + + + create("product") { + dimension = flavorDimensionName + signingConfig = signingConfigs.getByName("release") + + buildConfigString("HOST", "https://test.xxxxx.ai/") + buildConfigString("ABOUT_US", "https://test.xxxxx.ai/about") + buildConfigString("API_FROG", "https://test-frog.xxxxx.ai") + buildConfigString("EPAL_TERMS_SERVICES", "https://test.xxxxx.ai/policy/tos") + } + } +} + +dependencies { + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar", "*.aar"))) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") + + coreLibraryDesugaring(Deps.desugar) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + + + + //dagger hilt + implementation(Deps.hilt) + kapt(Deps.hiltAndroidCompiler) + kapt(Deps.hiltCompiler) + + //Retrofit2 + implementation(Deps.retrofit2) + implementation(Deps.retrofitAdapterRxjava2) + implementation(Deps.retrofit2ConverterGson) + implementation(Deps.okhttp3LoggingInterceptor) + + + // timber used for: recording logs + implementation(Deps.timber) + + + // tecent mmkv + implementation(Deps.mmkv) + implementation(Deps.material) + + // gson + implementation(Deps.gson) + + // viewpager2 + implementation(Deps.viewpager2) + + // for multiple dex support + implementation(Deps.multidex) + + + //google firebase + implementation(platform(Deps.firebaseBom)) + implementation(Deps.firebaseMessageKtx) + implementation(Deps.firebaseAnalyticsKtx) + implementation(Deps.firebaseCrashlyticsKtx) + implementation(Deps.firebaseAuthKtx) + implementation(Deps.credentials) + implementation(Deps.credentialsAuth) + implementation(Deps.googleId) + + + // coroutine support + implementation(Deps.kotlinCoroutinesCore) + implementation(Deps.kotlinCoroutinesAndroid) + + implementation(Deps.appcompat) + implementation(Deps.constraintlayout) + implementation(Deps.flexbox) + implementation(Deps.coreKtx) + implementation(Deps.activityKtx) + implementation(Deps.activityCompose) + implementation(Deps.fragmentKtx) + + // vm and lifecycle + implementation(Deps.viewModel) + implementation(Deps.livedata) + implementation(Deps.lifecycleJava8) + implementation(Deps.lifecycleRuntime) + implementation(Deps.datastore) + + + //glide + implementation(Deps.glide) + kapt(Deps.glideCompiler) + implementation(Deps.glideTransformations) + implementation(Deps.glideWebpdecoder) + + // eventbus + implementation(Deps.modularEventbus) + kapt(Deps.modularEventbusCompiler) + + // indicator + implementation(Deps.magicIndicator) + + // BlurView and Luban + implementation(Deps.Luban) + implementation(Deps.BlurView) + + //ali aRouter + implementation(Deps.arouter) + kapt(Deps.arouterCompiler) + + + //Permission + implementation(Deps.permission) + // lottie + implementation(Deps.lottie) + // float window + implementation(Deps.easyFloat) + // guide + implementation(Deps.newbieGuide) + // refreshLayout + implementation(Deps.refreshLayout) + // apng + implementation(Deps.apng) + // softKey board + implementation(Deps.skbGlobal) + + + // baseRecyclerAdapter + implementation(Deps.brvah) + implementation(Deps.swipeMenuLayout) + implementation(Deps.BRV) + + //banner + implementation(Deps.banner) + + // spannable + implementation(Deps.spannablex) + + // Media related libs + implementation(Deps.mp3Recorder) + implementation(Deps.photoView) + implementation(Deps.transition) + implementation(Deps.paging) + implementation(Deps.exoplayer) + implementation(Deps.subsamplingScaleImageView) + + + + implementation(project(mapOf("path" to ":loadingstateview"))) + implementation(project(mapOf("path" to ":loadingstateview-ktx"))) + implementation(project(mapOf("path" to ":viewbinding-base"))) + implementation(project(mapOf("path" to ":viewbinding-nonreflection-ktx"))) +} \ No newline at end of file diff --git a/VisualNovel/app/libs/ninepatch-1.0.aar b/VisualNovel/app/libs/ninepatch-1.0.aar new file mode 100644 index 0000000..1e55604 Binary files /dev/null and b/VisualNovel/app/libs/ninepatch-1.0.aar differ diff --git a/VisualNovel/app/proguard-rules.pro b/VisualNovel/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/VisualNovel/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/VisualNovel/app/src/androidTest/java/com/remax/visualnovel/ExampleInstrumentedTest.kt b/VisualNovel/app/src/androidTest/java/com/remax/visualnovel/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..d1d2008 --- /dev/null +++ b/VisualNovel/app/src/androidTest/java/com/remax/visualnovel/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.remax.visualnovel", appContext.packageName) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/assets/family/Bangers-400.ttf b/VisualNovel/app/src/main/assets/family/Bangers-400.ttf new file mode 100644 index 0000000..fa31c45 Binary files /dev/null and b/VisualNovel/app/src/main/assets/family/Bangers-400.ttf differ diff --git a/VisualNovel/app/src/main/assets/family/D-Din-700.ttf b/VisualNovel/app/src/main/assets/family/D-Din-700.ttf new file mode 100644 index 0000000..95f91b3 Binary files /dev/null and b/VisualNovel/app/src/main/assets/family/D-Din-700.ttf differ diff --git a/VisualNovel/app/src/main/assets/family/Poppins-400.ttf b/VisualNovel/app/src/main/assets/family/Poppins-400.ttf new file mode 100644 index 0000000..9f0c71b Binary files /dev/null and b/VisualNovel/app/src/main/assets/family/Poppins-400.ttf differ diff --git a/VisualNovel/app/src/main/assets/family/Poppins-500.ttf b/VisualNovel/app/src/main/assets/family/Poppins-500.ttf new file mode 100644 index 0000000..6bcdcc2 Binary files /dev/null and b/VisualNovel/app/src/main/assets/family/Poppins-500.ttf differ diff --git a/VisualNovel/app/src/main/assets/family/Poppins-600.ttf b/VisualNovel/app/src/main/assets/family/Poppins-600.ttf new file mode 100644 index 0000000..74c726e Binary files /dev/null and b/VisualNovel/app/src/main/assets/family/Poppins-600.ttf differ diff --git a/VisualNovel/app/src/main/assets/family/Poppins-700.ttf b/VisualNovel/app/src/main/assets/family/Poppins-700.ttf new file mode 100644 index 0000000..00559ee Binary files /dev/null and b/VisualNovel/app/src/main/assets/family/Poppins-700.ttf differ diff --git a/VisualNovel/app/src/main/assets/uitoken/token_sys.json b/VisualNovel/app/src/main/assets/uitoken/token_sys.json new file mode 100644 index 0000000..6593dc7 --- /dev/null +++ b/VisualNovel/app/src/main/assets/uitoken/token_sys.json @@ -0,0 +1,202 @@ +{ + "color.context.vip.normal": "@GRA:$glo.deg.ltr,$glo.color.red.20,$glo.color.violet.20,$glo.color.mint.20", + "glo.color.orange.10": "$glo.color.orange.10", + + + "color.primary.normal": "$glo.color.magenta.50", + "color.primary.hover": "$glo.color.magenta.40", + "color.primary.press": "$glo.color.magenta.60", + "color.primary.disabled": "$color.surface.nest.disabled", + "color.primary.variant.normal": "$glo.color.magenta.40", + "color.primary.variant.hover": "$glo.color.magenta.30", + "color.primary.variant.press": "$glo.color.magenta.50", + "color.primary.variant.disabled": "$color.surface.nest.disabled", + "color.secondary.gradient.normal": "@GRA:$glo.deg.ltr,$glo.color.red.20,$glo.color.violet.20,$glo.color.mint.20", + "color.primary.gradient.normal": "@GRA:$glo.deg.ltr,$glo.color.magenta.30,$glo.color.purple.40", + "color.primary.gradient.hover": "@GRA:$glo.deg.ltr,$glo.color.magenta.20,$glo.color.purple.30", + "color.primary.gradient.press": "@GRA:$glo.deg.ltr,$glo.color.magenta.40,$glo.color.purple.50", + "color.primary.gradient.disabled": "$color.surface.nest.disabled", + "color.primary.onpic.normal": "$glo.color.violet.40,$glo.transparent.t85", + "color.primary.onpic.hover": "$glo.color.violet.30,$glo.transparent.t85", + "color.primary.onpic.press": "$glo.color.violet.50,$glo.transparent.t85", + "color.important.normal": "$glo.color.red.50", + "color.important.hover": "$glo.color.red.40", + "color.important.press": "$glo.color.red.60", + "color.important.disabled": "$color.surface.nest.disabled", + "color.important.variant.normal": "$glo.color.red.40", + "color.important.variant.hover": "$glo.color.red.30", + "color.important.variant.press": "$glo.color.red.50", + "color.important.variant.disabled": "$glo.color.blue.10,$glo.transparent.t25", + "color.important.gradient.normal": "@GRA:$glo.deg.ltr,$glo.color.orange.50,$glo.color.red.50", + "color.important.gradient.hover": "@GRA:$glo.deg.ltr,$glo.color.orange.40,$glo.color.red.40", + "color.important.gradient.press": "@GRA:$glo.deg.ltr,$glo.color.orange.60,$glo.color.red.60", + "color.important.gradient.disabled": "$color.surface.nest.disabled", + "color.important.onpic.normal": "$glo.color.red.50,$glo.transparent.t85", + "color.positive.normal": "$glo.color.mint.60", + "color.positive.hover": "$glo.color.mint.50", + "color.positive.press": "$glo.color.mint.70", + "color.positive.disabled": "$color.surface.nest.disabled", + "color.positive.variant.normal": "$glo.color.mint.40", + "color.positive.variant.hover": "$glo.color.mint.30", + "color.positive.variant.press": "$glo.color.mint.50", + "color.positive.variant.disabled": "$glo.color.blue.10,$glo.transparent.t25", + "color.positive.gradient.normal": "@GRA:$glo.deg.ltr,$glo.color.green.40,$glo.color.mint.60", + "color.positive.gradient.hover": "$glo.border.1", + "color.positive.gradient.press": "@GRA:$glo.deg.ltr,$glo.color.green.60,$glo.color.mint.70", + "color.positive.gradient.disabled": "$color.surface.nest.disabled", + "color.positive.onpic.normal": "$glo.color.mint.60,$glo.transparent.t85", + "color.warning.normal": "$glo.color.orange.50", + "color.warning.hover": "$glo.color.orange.40", + "color.warning.press": "$glo.color.orange.60", + "color.warning.disabled": "$color.surface.nest.disabled", + "color.warning.variant.normal": "$glo.color.orange.40", + "color.warning.variant.hover": "$glo.color.orange.30", + "color.warning.variant.press": "$glo.color.orange.50", + "color.warning.variant.disabled": "$glo.color.blue.10,$glo.transparent.t25", + "color.warning.gradient.normal": "@GRA:$glo.deg.ltr,$glo.color.yellow.40,$glo.color.orange.50", + "color.warning.gradient.hover": "@GRA:$glo.deg.ltr,$glo.color.yellow.30,$glo.color.orange.40", + "color.warning.gradient.press": "@GRA:$glo.deg.ltr,$glo.color.yellow.50,$glo.color.orange.60", + "color.warning.gradient.disabled": "$color.surface.nest.disabled", + "color.warning.onpic.normal": "$glo.color.orange.50,$glo.transparent.t85", + "color.emphasis.normal": "$glo.color.blue.40", + "color.emphasis.hover": "$glo.color.blue.30", + "color.emphasis.press": "$glo.color.blue.50", + "color.emphasis.disabled": "$color.surface.nest.disabled", + "color.emphasis.variant.normal": "$glo.color.blue.30", + "color.emphasis.variant.hover": "$glo.color.blue.20", + "color.emphasis.variant.press": "$glo.color.blue.40", + "color.emphasis.variant.disabled": "$glo.color.blue.10,$glo.transparent.t25", + "color.emphasis.grandient.normal": "@GRA:$glo.deg.ltr,$glo.color.sky.30,$glo.color.blue.40", + "color.emphasis.grandient.hover": "@GRA:$glo.deg.ltr,$glo.color.sky.20,$glo.color.blue.30", + "color.emphasis.grandient.press": "@GRA:$glo.deg.ltr,$glo.color.sky.40,$glo.color.blue.50", + "color.emphasis.grandient.disabled": "$color.surface.nest.disabled", + "color.emphasis.onpic.normal": "$glo.color.blue.40,$glo.transparent.t85", + "color.background.default": "$glo.color.grey.100", + "color.background.specialmap": "$glo.color.grey.100", + "color.background.district": "$glo.color.black,$glo.transparent.t30", + "color.surface.base.normal": "$glo.color.grey.80", + "color.surface.base.hover": "$glo.color.grey.70", + "color.surface.base.press": "$glo.color.grey.90", + "color.surface.base.disabled": "$glo.color.grey.90", + "color.surface.base.specialmap.normal": "$glo.color.grey.80", + "color.surface.base.specialmap.hover": "$glo.color.grey.70", + "color.surface.base.specialmap.press": "$glo.color.grey.90,$glo.transparent.t30", + "color.surface.base.specialmap.disabled": "$glo.color.white,$glo.transparent.t8", + "color.surface.float.normal": "$glo.color.grey.70", + "color.surface.float.hover": "$glo.color.grey.60", + "color.surface.float.press": "$glo.color.grey.80", + "color.surface.float.disabled": "$glo.color.grey.80", + "color.surface.top.normal": "$glo.color.black,$glo.transparent.t65", + "color.surface.top.hover": "$glo.color.black,$glo.transparent.t45", + "color.surface.top.press": "$glo.color.black,$glo.transparent.t85", + "color.surface.top.disabled": "$glo.color.black,$glo.transparent.t30", + "color.surface.district.normal": "$glo.color.purple.0,$glo.transparent.t4", + "color.surface.district.hover": "$glo.color.purple.0,$glo.transparent.t12", + "color.surface.district.press": "$glo.color.black,$glo.transparent.t25", + "color.surface.district.disabled": "$glo.color.black,$glo.transparent.t25", + "color.surface.nest.normal": "$glo.color.purple.0,$glo.transparent.t8", + "color.surface.nest.hover": "$glo.color.purple.0,$glo.transparent.t12", + "color.surface.nest.press": "$glo.color.purple.0,$glo.transparent.t4", + "color.surface.nest.disabled": "$glo.color.purple.0,$glo.transparent.t4", + "color.surface.element.normal": "$color.surface.nest.normal", + "color.surface.element.hover": "$color.surface.nest.hover", + "color.surface.element.press": "$color.surface.nest.press", + "color.surface.element.disabled": "$color.surface.nest.disabled", + "color.surface.element.dark.normal": "$glo.color.black,$glo.transparent.t65", + "color.surface.element.dark.hover": "$glo.color.black,$glo.transparent.t45", + "color.surface.element.dark.press": "$glo.color.black,$glo.transparent.t85", + "color.surface.element.dark.disabled": "$glo.color.black,$glo.transparent.t45", + "color.surface.element.light.normal": "$glo.color.white,$glo.transparent.t15", + "color.surface.element.light.hover": "$glo.color.white,$glo.transparent.t25", + "color.surface.element.light.press": "$glo.color.white,$glo.transparent.t8", + "color.surface.element.light.disabled": "$glo.color.white,$glo.transparent.t8", + "color.surface.white.normal": "$glo.color.white", + "color.surface.white.hover": "$glo.color.white,$glo.transparent.t85", + "color.surface.white.press": "$glo.color.white,$glo.transparent.t65", + "color.surface.white.disabled": "$glo.color.white,$glo.transparent.t45", + "color.outline.normal": "$glo.color.purple.0,$glo.transparent.t20", + "color.outline.hover": "$glo.color.purple.0,$glo.transparent.t30", + "color.outline.press": "$glo.color.purple.0,$glo.transparent.t8", + "color.outline.disabled": "$color.surface.element.disabled", + "color.overlay.primary": "$glo.color.violet.30,$glo.transparent.t30", + "color.overlay.gradient": "@GRA:$glo.deg.ttb,$glo.color.black&$glo.transparent.t0,$glo.color.black&$glo.transparent.t100", + "color.overlay.dark": "$glo.color.black,$glo.transparent.t65", + "color.overlay.base": "@GRA:$glo.deg.ttb,$glo.color.grey.80&$glo.transparent.t0,$glo.color.grey.80&$glo.transparent.t100", + "color.context.subscribe.normal": "@GRA:$glo.deg.ltr,$glo.color.purple.50,$glo.color.violet.50", + "color.context.subscribe.hover": "@GRA:$glo.deg.ltr,$glo.color.purple.30,$glo.color.violet.30", + "color.context.subscribe.press": "@GRA:$glo.deg.ltr,$glo.color.purple.70,$glo.color.violet.70", + "color.context.subscribe.disabled": "$color.surface.nest.disabled", + "color.context.legends.normal": "@GRA:$glo.deg.ltr,$glo.color.yellow.20,$glo.color.yellow.60", + "color.context.legends.hover": "@GRA:$glo.deg.ltr,$glo.color.yellow.10,$glo.color.yellow.40", + "color.context.legends.press": "@GRA:$glo.deg.ltr,$glo.color.yellow.60,$glo.color.yellow.90", + "color.context.legends.disabled": "$color.surface.nest.disabled", + "color.context.legends.variant.normal": "$glo.color.yellow.20", + "color.context.legends.variant.hover": "$glo.color.yellow.10", + "color.context.legends.variant.press": "$glo.color.yellow.40", + "color.context.legends.variant.disabled": "$color.surface.nest.disabled", + "color.txt.primary.normal": "$glo.color.white", + "color.txt.primary.hover": "$glo.color.magenta.30", + "color.txt.primary.press": "$glo.color.magenta.40", + "color.txt.primary.disabled": "$color.txt.disabled", + "color.txt.primary.specialmap.normal": "$glo.color.white", + "color.txt.primary.specialmap.hover": "$glo.color.white,$glo.transparent.t85", + "color.txt.primary.specialmap.press": "$glo.color.white,$glo.transparent.t65", + "color.txt.primary.specialmap.disabled": "$glo.color.white,$glo.transparent.t45", + "color.txt.secondary.normal": "$glo.color.grey.30", + "color.txt.secondary.hover": "$glo.color.magenta.30", + "color.txt.secondary.press": "$glo.color.magenta.40", + "color.txt.secondary.disabled": "$glo.color.grey.50", + "color.txt.tertiary.normal": "$glo.color.grey.40", + "color.txt.tertiary.hover": "$glo.color.grey.30", + "color.txt.tertiary": "$glo.color.grey.50", + "color.txt.tertiary.disabled": "$color.txt.disabled", + "color.txt.grass": "$glo.color.grass.40", + "color.txt.disabled": "$glo.color.grey.50", + "txt.display.xl": "$glo.font.family.display,$glo.font.size.64,$glo.font.weight.regular,$glo.font.lineheight.size64", + "txt.display.l": "$glo.font.family.display,$glo.font.size.36,$glo.font.weight.regular,$glo.font.lineheight.size36", + "txt.display.m": "$glo.font.family.display,$glo.font.size.24,$glo.font.weight.regular,$glo.font.lineheight.size24", + "txt.display.s": "$glo.font.family.display,$glo.font.size.16,$glo.font.weight.regular,$glo.font.lineheight.size16", + "txt.headline.l": "$glo.font.family.sys,$glo.font.size.36,$glo.font.weight.bold,$glo.font.lineheight.size36", + "txt.headline.m": "$glo.font.family.sys,$glo.font.size.24,$glo.font.weight.bold,$glo.font.lineheight.size24", + "txt.headline.s": "$glo.font.family.sys,$glo.font.size.20,$glo.font.weight.bold,$glo.font.lineheight.size20", + "txt.title.l": "$glo.font.family.sys,$glo.font.size.20,$glo.font.weight.semibold,$glo.font.lineheight.size20", + "txt.title.m": "$glo.font.family.sys,$glo.font.size.18,$glo.font.weight.semibold,$glo.font.lineheight.size18", + "txt.title.s": "$glo.font.family.sys,$glo.font.size.16,$glo.font.weight.semibold,$glo.font.lineheight.size16", + "txt.bodySemibold.l": "$glo.font.family.sys,$glo.font.size.16,$glo.font.weight.semibold,$glo.font.lineheight.size16", + "txt.bodySemibold.m": "$glo.font.family.sys,$glo.font.size.14,$glo.font.weight.semibold,$glo.font.lineheight.size14", + "txt.bodySemibold.s": "$glo.font.family.sys,$glo.font.size.12,$glo.font.weight.semibold,$glo.font.lineheight.size12", + "txt.body.l": "$glo.font.family.sys,$glo.font.size.16,$glo.font.weight.regular,$glo.font.lineheight.size16", + "txt.body.m": "$glo.font.family.sys,$glo.font.size.14,$glo.font.weight.regular,$glo.font.lineheight.size14", + "txt.body.s": "$glo.font.family.sys,$glo.font.size.12,$glo.font.weight.regular,$glo.font.lineheight.size12", + "txt.label.l": "$glo.font.family.sys,$glo.font.size.16,$glo.font.weight.medium,$glo.font.lineheight.size16", + "txt.label.m": "$glo.font.family.sys,$glo.font.size.14,$glo.font.weight.medium,$glo.font.lineheight.size14", + "txt.label.s": "$glo.font.family.sys,$glo.font.size.12,$glo.font.weight.medium,$glo.font.lineheight.size12", + "txt.numDisplay.xl": "$glo.font.family.numDisplay,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48", + "txt.numDisplay.l": "$glo.font.family.numDisplay,$glo.font.size.36,$glo.font.weight.bold,$glo.font.lineheight.size36", + "txt.numDisplay.m": "$glo.font.family.numDisplay,$glo.font.size.24,$glo.font.weight.bold,$glo.font.lineheight.size24", + "txt.numDisplay.s": "$glo.font.family.numDisplay,$glo.font.size.20,$glo.font.weight.bold,$glo.font.lineheight.size20", + "txt.numMonotype.xl": "$glo.font.family.sys,$glo.font.size.20,$glo.font.weight.bold,$glo.font.lineheight.size20", + "txt.numMonotype.l": "$glo.font.family.sys,$glo.font.size.18,$glo.font.weight.bold,$glo.font.lineheight.size18", + "txt.numMonotype.m": "$glo.font.family.sys,$glo.font.size.16,$glo.font.weight.bold,$glo.font.lineheight.size16", + "txt.numMonotype.s": "$glo.font.family.sys,$glo.font.size.14,$glo.font.weight.medium,$glo.font.lineheight.size14", + "txt.numMonotype.xs": "$glo.font.family.sys,$glo.font.size.12,$glo.font.weight.medium,$glo.font.lineheight.size12", + "shadow.s": "@SHA:$glo.color.black,$glo.transparent.t15,0&0,4", + "shadow.m": "@SHA:$glo.color.black,$glo.transparent.t15,0&0,8", + "shadow.l": "@SHA:$glo.color.black,$glo.transparent.t15,0&0,16", + "radius.xs": "$glo.radius.4", + "radius.s": "$glo.radius.8", + "radius.m": "$glo.radius.12", + "radius.l": "$glo.radius.16", + "radius.xl": "$glo.radius.20", + "radius.xxl": "$glo.radius.24", + "radius.40": "$glo.radius.40", + "radius.42": "$glo.radius.42", + "radius.80": "$glo.radius.80", + "radius.round": "$glo.radius.round", + "radius.pill": "$glo.radius.round", + "border.divider": "$glo.border.half", + "border.s": "$glo.border.1", + "border.m": "$glo.border.2", + "border.l": "$glo.border.4" +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/factory/ServiceFactory.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/factory/ServiceFactory.kt new file mode 100644 index 0000000..5dfe8fd --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/factory/ServiceFactory.kt @@ -0,0 +1,72 @@ +package com.remax.visualnovel.api.factory + +import androidx.core.net.toUri +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.api.interceptor.GlobalInterceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object ServiceFactory { + /** + * 受信任的host列表 + */ + private val hostNameList: List by lazy { + listOf( + BuildConfig.API_FROG, + BuildConfig.HOST + ).map { + it.parseHost() + } + } + + private fun String.parseHost() = this.toUri().host + + private val retrofit: Retrofit by lazy { + createRetrofit() + } + + private val okHttpClient: OkHttpClient by lazy { + createOkHttpClient() + } + + /** + * 构建OkHttpClient对象 + */ + private fun createOkHttpClient() = OkHttpClient.Builder() + .addInterceptor(GlobalInterceptor()) + .callTimeout(30, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .hostnameVerifier { hostname, _ -> + hostname in hostNameList + } + .apply { + if (BuildConfig.DEBUG) { + val interceptor = HttpLoggingInterceptor() + interceptor.level = HttpLoggingInterceptor.Level.BODY + addNetworkInterceptor(interceptor) + } + }.build() + + /** + * 构建Retrofit对象 + */ + private fun createRetrofit() = Retrofit.Builder() + .baseUrl(BuildConfig.API_FROG) + .client(okHttpClient) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + inline fun createService(): T = create(T::class.java) + + fun create(clazz: Class): T { + return retrofit.create(clazz) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt new file mode 100644 index 0000000..222d690 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt @@ -0,0 +1,89 @@ +package com.remax.visualnovel.api.interceptor + + +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.constant.AppConstant +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.AppUtils +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.asResponseBody +import okio.Buffer +import timber.log.Timber +import java.io.IOException + +/** + * Created by HJW on 2022/10/17 + */ +class GlobalInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + val bodyStr = readBody(chain.request().body) + val emptyBody = "{}".toRequestBody("application/json;charset=utf-8".toMediaType()) + val requestBody = if (bodyStr.isNotBlank()) request.body else emptyBody + +// val platform = "ANDROID_${BuildConfig.VERSION_NAME}" +// val userAgent = String.format( +// "E-Pal/%s (%s; android %s)", +// BuildConfig.VERSION_NAME, +// Build.MODEL, +// Build.VERSION.RELEASE +// ) + + val headersBuilder = Headers.Builder() + .add("AUTH_TK", LoginManager.token ?: "") + .add("AUTH_DID", AppUtils.getAndroidID()) + .add("platform", AppConstant.APP_CLIENT) + .add("versionNum", "100") + + val headers = headersBuilder.build() + + request = chain.request().newBuilder() + .headers(headers) + .post(requestBody ?: emptyBody) + .build() + + val response = chain.proceed(request) + + if (BuildConfig.DEBUG) { + try { + Timber.tag("发起请求") + Timber.d( + """ + ———————————————— 我是开始分割线 —————————————————————————————— + ${request.url} + 入参: + ${readBody(requestBody)} + 响应: + ${clone(response.body)?.string()} + ———————————————— 我是结束分割线 ——————————————————————————————— + """.trimIndent() + ) + } catch (e: Exception) { + Timber.e("GlobalInterceptor request.exception : ${e.localizedMessage}}") + } + } + + return response + } + + private fun readBody(body: RequestBody?): String { + val buffer = Buffer() + body?.writeTo(buffer) + return buffer.readUtf8() + } + + @Throws(IOException::class) + private fun clone(body: ResponseBody?): ResponseBody? { + val source = body?.source() + if (source?.request(Long.MAX_VALUE) == true) throw IOException("body too long!") + val bufferedCopy = source?.buffer?.clone() + return bufferedCopy?.asResponseBody(body.contentType(), body.contentLength()) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java new file mode 100644 index 0000000..d273012 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java @@ -0,0 +1,102 @@ +package com.remax.visualnovel.api.interceptor.util; + +import java.io.UnsupportedEncodingException; + +public class Base64 { + + private static final char[] ENCODE_CHARS = new char[]{'A', 'B', 'C', 'D', + 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', + 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', + 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', + 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', '+', '/'}; + + private static final byte[] DECODE_CHARS = new byte[]{-1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, + -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, + 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, + -1, -1}; + + public static String encode(byte[] data) { + StringBuffer buffer = new StringBuffer(); + int length = data.length; + int indexx = 0; + int b1, b2, b3; + while (indexx < length) { + b1 = data[indexx++] & 0xff; + if (indexx == length) { + buffer.append(ENCODE_CHARS[b1 >>> 2]); + buffer.append(ENCODE_CHARS[(b1 & 0x3) << 4]); + buffer.append("=="); + break; + } + b2 = data[indexx++] & 0xff; + if (indexx == length) { + buffer.append(ENCODE_CHARS[b1 >>> 2]); + buffer.append(ENCODE_CHARS[((b1 & 0x03) << 4) | ((b2 & 0xf0) >>> 4)]); + buffer.append(ENCODE_CHARS[(b2 & 0x0f) << 2]); + buffer.append("="); + break; + } + b3 = data[indexx++] & 0xff; + buffer.append(ENCODE_CHARS[b1 >>> 2]); + buffer.append(ENCODE_CHARS[((b1 & 0x03) << 4) | ((b2 & 0xf0) >>> 4)]); + buffer.append(ENCODE_CHARS[((b2 & 0x0f) << 2) | ((b3 & 0xc0) >>> 6)]); + buffer.append(ENCODE_CHARS[b3 & 0x3f]); + } + return buffer.toString(); + } + + public static byte[] decode(String str) throws UnsupportedEncodingException { + StringBuffer sb = new StringBuffer(); + byte[] data = str.getBytes("US-ASCII"); + int len = data.length; + int i = 0; + int b1, b2, b3, b4; + while (i < len) { + do { + b1 = DECODE_CHARS[data[i++]]; + } while (i < len && b1 == -1); + if (b1 == -1) { + break; + } + + do { + b2 = DECODE_CHARS[data[i++]]; + } while (i < len && b2 == -1); + if (b2 == -1) { + break; + } + sb.append((char) ((b1 << 2) | ((b2 & 0x30) >>> 4))); + + do { + b3 = data[i++]; + if (b3 == 61) { + return sb.toString().getBytes("iso8859-1"); + } + b3 = DECODE_CHARS[b3]; + } while (i < len && b3 == -1); + if (b3 == -1) { + break; + } + sb.append((char) (((b2 & 0x0f) << 4) | ((b3 & 0x3c) >>> 2))); + + do { + b4 = data[i++]; + if (b4 == 61) { + return sb.toString().getBytes("iso8859-1"); + } + b4 = DECODE_CHARS[b4]; + } while (i < len && b4 == -1); + if (b4 == -1) { + break; + } + sb.append((char) (((b3 & 0x03) << 6) | b4)); + } + return sb.toString().getBytes("iso8859-1"); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java new file mode 100644 index 0000000..1c6b222 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java @@ -0,0 +1,37 @@ +package com.remax.visualnovel.api.interceptor.util; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Md5 { + + private Md5() { + } + + public static String encode(String str) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(str.getBytes()); + byte[] byteDigest = md.digest(); + StringBuilder buf = new StringBuilder(); + + for (int b : byteDigest) { + int i = b; + if (i < 0) { + i += 256; + } + + if (i < 16) { + buf.append("0"); + } + + buf.append(Integer.toHexString(i)); + } + + return buf.toString(); + } catch (NoSuchAlgorithmException var6) { + var6.printStackTrace(); + return ""; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt new file mode 100644 index 0000000..dfc9a44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.POST + + +interface BookService { + @POST("/web/si/asi") + suspend fun getBooks(): Response + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt new file mode 100644 index 0000000..343dd38 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt @@ -0,0 +1,33 @@ +package com.remax.visualnovel.api.service + + +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface DictService { + + /** + * 获取聊天气泡字典 + */ + /*@POST("/web/chat-set/get-chat-bubble-list") + suspend fun getChatBubbleList(@Body request: AIIDRequest): Response>*/ + + /** + * AI标签 + */ + /*@POST("/web/get-ai-dict") + suspend fun getAIDict(): Response*/ + + /** + * 礼物字典 + */ + /*@POST("/web/gift/dict-list") + suspend fun getGiftDict(@Body pageQuery: PageQuery = PageQuery(1).apply { page.ps = 100 }): Response>*/ + + /** + * chat模型 + */ + /*@POST("/web/chat-model/dict-list") + suspend fun getAIChatModel(): Response>*/ +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt new file mode 100644 index 0000000..3592999 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.entity.request.PlatformAccountVerifyDTO +import com.remax.visualnovel.entity.response.PlatformAccountVerify +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface LoginService { + + /** + * 重复昵称验证 + */ + @POST("/web/user/nickname-check") + suspend fun checkUserNickname(@Body request: CompleteUserInfoInput): Response + + /** + * 三方账号验证 + */ + @POST("/web/third/login") + suspend fun platformThirdVerify(@Body request: PlatformAccountVerifyDTO): Response + + @POST("/web/user/logout") + suspend fun logout(): Response + + @POST("/web/user/complete-user-info") + suspend fun register(@Body request: CompleteUserInfoInput): Response + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt new file mode 100644 index 0000000..fcba604 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt @@ -0,0 +1,34 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface MessageService { + +// /** +// * 删除会话 +// */ +// @POST(BuildConfig.API_COW + "/web/ai-message/del") +// suspend fun deleteConversation(@Body request: AIListRequest): Response +// +// /** +// * 送礼物 +// */ +// @POST("/web/ai-user-gift/send") +// suspend fun sendGift(@Body dto: SendGift): Response +// +// /** +// * 未读消息统计 +// */ +// @POST(BuildConfig.API_PIGEON + "/web/message/stat") +// suspend fun getMessageStat(): Response +// +// /** +// * 系统通知列表 +// */ +// @POST(BuildConfig.API_PIGEON + "/web/message/list") +// suspend fun getMessageList(@Body dto: PageQuery): Response> + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt new file mode 100644 index 0000000..a295567 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt @@ -0,0 +1,52 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.entity.response.Character +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface UserService{ + + /** + * 签到 + */ + @POST("/web/si/asi") + suspend fun signToday(): Response + + /** + * 获取签到周期数据 + */ + /*@POST("/web/si/list") + suspend fun getSignList(): Response*/ + + /** + * 获取登录用户基础信息 + */ + @POST("/web/user/base-info") + suspend fun getMyBaseInfo(): Response + + /** + * 获取me页面的ai列表 + */ + @POST("/web/ai-user-search/base-list") + suspend fun getMyCharactersList(): Response> + + /** + * 删除账号 + */ + @POST("/web/user/del") + suspend fun deleteAccount(): Response + + @POST("/web/user/edit-user-info") + suspend fun updateUserInfo(@Body request: CompleteUserInfoInput): Response + + /** + * 获取云信appKey account token + */ + /*@POST(BuildConfig.API_PIGEON + "/web/im-user/get-account") + suspend fun getNimInfo(): Response*/ + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt new file mode 100644 index 0000000..86d99c0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt @@ -0,0 +1,45 @@ +package com.remax.visualnovel.app + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.remax.visualnovel.app.initializer.utils.FirebaseHelper +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel + + +/** + * Created by HJW on 2022/9/21 + * 监听整个app生命周期 + */ +object ProcessLifecycleObserver : DefaultLifecycleObserver { + + var isOnResume = false + private var refreshFirebaseTokenTime = 0L + + private var appGlobalViewModel: AppGlobalViewModel? = null + + fun setAppGlobalViewModel(appGlobalViewModel: AppGlobalViewModel) { + ProcessLifecycleObserver.appGlobalViewModel = appGlobalViewModel + } + + /** + * APP在前台回调 + */ + override fun onResume(owner: LifecycleOwner) { + isOnResume = true + val currTime = System.currentTimeMillis() + if (refreshFirebaseTokenTime != 0L && currTime - refreshFirebaseTokenTime > 60 * 1000) { + refreshFirebaseTokenTime = currTime + FirebaseHelper.getToken { + appGlobalViewModel?.updateTerminal(it) + } + } + + } + + /** + * APP进入后台回调 + */ + override fun onPause(owner: LifecycleOwner) { + isOnResume = false + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt new file mode 100644 index 0000000..faec652 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.app.activityresultapi + +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract + +class XActivityResultContract( + activityResultCaller: ActivityResultCaller, + activityResultContract: ActivityResultContract +) { + + private var activityResultCallback: ActivityResultCallback? = null + + + private val launcher: ActivityResultLauncher = + activityResultCaller.registerForActivityResult(activityResultContract) { + activityResultCallback?.onActivityResult(it) + } + + + /** + * 启动 + */ + fun launch(input: I, activityResultCallback: ActivityResultCallback?) { + this.activityResultCallback = activityResultCallback + launcher.launch(input) + } + + /** + * 注销 + */ + fun unregister() { + launcher.unregister() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt new file mode 100644 index 0000000..9db3910 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt @@ -0,0 +1,144 @@ +package com.remax.visualnovel.app.base + +import android.content.res.Resources +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding +import com.remax.visualnovel.app.AbsView +import com.remax.visualnovel.app.widget.LoadingDialog +import com.remax.visualnovel.extension.fixedFontSize +import com.remax.visualnovel.extension.getBgColor +import com.remax.visualnovel.extension.isShouldHideKeyboard +import com.remax.visualnovel.extension.setStatusBarColor +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.extension.transitionFromAlpha +import com.remax.visualnovel.extension.transitionFromBottom +import com.remax.visualnovel.extension.withTransitionFromAlpha +import com.remax.visualnovel.extension.withTransitionFromBottom +import com.remax.visualnovel.utils.KeyboardUtils +import com.remax.visualnovel.utils.StatusBarUtils +import com.dylanc.loadingstateview.ActivityTransitionType +import com.dylanc.loadingstateview.BgColorType +import com.dylanc.loadingstateview.Decorative +import com.dylanc.loadingstateview.LoadingState +import com.dylanc.loadingstateview.LoadingStateDelegate +import com.dylanc.loadingstateview.OnReloadListener +import com.dylanc.viewbinding.base.ActivityBinding +import com.dylanc.viewbinding.base.ActivityBindingDelegate + + +/** + * Activity基类 + */ +abstract class BaseBindingActivity : AppCompatActivity(), AbsView, + LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative, + ActivityBinding by ActivityBindingDelegate() { + + private val loadingDialog by lazy { + createLoadingDialog() + } + + private fun createLoadingDialog(): LoadingDialog { + val dialog = LoadingDialog() + dialog.build(this) + return dialog + } + + override fun getResources(): Resources { + return super.getResources().fixedFontSize(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentViewWithBinding() + binding.root.decorate(this, this) + StatusBarUtils.setStatusBarAndNavBarIsLight(this, false) + setStatusBarColor(backgroundColorType()) + binding.root.setBackgroundColor(getBgColor(backgroundColorType())) + when (transitionType()) { + ActivityTransitionType.BOTTOM -> withTransitionFromBottom() + ActivityTransitionType.ALPHA -> withTransitionFromAlpha() + else -> {} + } + initView() + initData() + subscribeUi() + } + + protected open fun backgroundColorType() = BgColorType.SPECIAL_MAP + + protected abstract fun initView() + protected open fun subscribeUi() {} + protected open fun initData() {} + + override fun onResume() { + super.onResume() + } + + override fun onPause() { + super.onPause() + if (KeyboardUtils.isSoftInputVisible(this)) { + KeyboardUtils.hideSoftInput(this) + } + } + + override fun finish() { + super.finish() + when (transitionType()) { + ActivityTransitionType.BOTTOM -> transitionFromBottom() + ActivityTransitionType.ALPHA -> transitionFromAlpha() + else -> {} + } + } + + protected open var touchEventFun: ((MotionEvent) -> Unit)? = null + protected open fun touchHideKeyboardViewList(): List? = null + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + touchEventFun?.let { + runCatching { ev?.run(it) } + } + if (touchHideKeyboardViewList() != null) { + if (ev?.action == MotionEvent.ACTION_DOWN && KeyboardUtils.isSoftInputVisible(this) && touchHideKeyboardViewList()?.any { + it.isShouldHideKeyboard( + ev + ) + } == true) { + KeyboardUtils.hideSoftInput(this) + } + } + return super.dispatchTouchEvent(ev) + } + + /** + * 页面开关方向 + */ + protected open fun transitionType() = ActivityTransitionType.DEFAULT + + override fun showLoading() { + if (!this.isDestroyed) { + runOnUiThread { + loadingDialog.show() + } + } + } + + override fun hideLoading() { + if (!this.isDestroyed) { + runOnUiThread { + loadingDialog.dismiss() + } + } + } + + override fun showToast(text: String?) { + toast(text) + } + + override fun showToast(resId: Int) { + toast(resId) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt new file mode 100644 index 0000000..bb2c129 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt @@ -0,0 +1,112 @@ +package com.remax.visualnovel.app.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.dylanc.loadingstateview.BgColorType +import com.dylanc.loadingstateview.Decorative +import com.dylanc.loadingstateview.LoadingState +import com.dylanc.loadingstateview.LoadingStateDelegate +import com.dylanc.loadingstateview.OnReloadListener +import com.dylanc.viewbinding.base.FragmentBinding +import com.dylanc.viewbinding.base.FragmentBindingDelegate +import com.remax.visualnovel.app.AbsView +import com.remax.visualnovel.app.widget.LoadingDialog +import com.remax.visualnovel.extension.getBgColor +import com.remax.visualnovel.extension.toast + +/** + * 基类Fragment + */ +abstract class BaseBindingFragment : Fragment(), AbsView, + LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative, + FragmentBinding by FragmentBindingDelegate() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = createViewWithBinding(inflater, container).decorate(this, this) + view.setBackgroundColor(requireContext().getBgColor(backgroundColorType())) + return view + } + + open fun backgroundColorType() = BgColorType.SPECIAL_MAP + + private val loadingDialog by lazy { + createLoadingDialog() + } + + private var isLoaded = false + + private fun createLoadingDialog(): LoadingDialog { + val dialog = LoadingDialog() + dialog.build(requireActivity()) + return dialog + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onCreated(arguments) + subscribeUi() + } + + override fun onResume() { + super.onResume() + if (!isLoaded && !isHidden) { + lazyInit() + isLoaded = true + } + } + + abstract fun onCreated(bundle: Bundle?) + + open fun lazyInit() {} + + open fun subscribeUi() {} + + override fun showLoading() { + if (activity?.isDestroyed == false) { + activity?.runOnUiThread { + loadingDialog.show() + } + } + } + + override fun hideLoading() { + if (activity?.isDestroyed == false) { + activity?.runOnUiThread { + loadingDialog.dismiss() + } + } + } + + override fun showToast(text: String?) { + if (!isDetached) { + activity?.let { + if (!it.isDestroyed) { + it.toast(text) + } + } + } + } + + override fun showToast(resId: Int) { + if (!isDetached) { + activity?.let { + if (!it.isDestroyed) { + it.toast(resId) + } + } + } + } + + override fun onDetach() { + if (loadingDialog.getDialog().isShowing) { + loadingDialog.dismiss() + } + super.onDetach() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt new file mode 100644 index 0000000..b5a6794 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.app.base + +import androidx.viewpager.widget.ViewPager +import androidx.viewpager2.widget.ViewPager2 +import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.CommonNavigatorAdapter + +/** + * Created by HJW on 2023/7/18 + */ +abstract class BaseCommonNavigatorAdapter( + open val viewPager2: ViewPager2?, + open val viewPager: ViewPager? = null +) : CommonNavigatorAdapter() \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt new file mode 100644 index 0000000..f4df7e6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.app.base.app + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel +import com.remax.visualnovel.app.viewmodel.AppIMViewModel +import com.remax.visualnovel.repository.api.MessageRepository +import com.remax.visualnovel.repository.api.UserRepository +import javax.inject.Inject + +/** + * 用于创建[AppIMViewModel, AppGlobalViewModel]等实例 + */ +class AppViewModelFactory @Inject constructor( + private val application: Application, + private val userRepository: UserRepository, + private val messageRepository: MessageRepository +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + return when (modelClass) { + AppIMViewModel::class.java -> AppIMViewModel(application) + AppGlobalViewModel::class.java -> AppGlobalViewModel(application, userRepository, messageRepository) + else -> throw IllegalArgumentException("Unknown class $modelClass") + } as T + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt new file mode 100644 index 0000000..4167748 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.app.base.app + +import android.app.Application + +interface ApplicationProxy { + + fun onCreate(application: Application) + + fun onTerminate() + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt new file mode 100644 index 0000000..95f199d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt @@ -0,0 +1,22 @@ +package com.remax.visualnovel.app.base.app + +import android.app.Application +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +object CommonApplicationProxy : ApplicationProxy, ViewModelStoreOwner { + + lateinit var application: Application + private set + + override val viewModelStore = ViewModelStore() + + override fun onCreate(application: Application) { + CommonApplicationProxy.application = application + } + + override fun onTerminate() { + viewModelStore.clear() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt new file mode 100644 index 0000000..8d64ef3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt @@ -0,0 +1,264 @@ +package com.remax.visualnovel.app.delegate + +import android.app.Activity +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.LayoutToolbarBinding +import com.remax.visualnovel.extension.findActivityContext +import com.remax.visualnovel.extension.getBgColor +import com.remax.visualnovel.extension.getNavHeight +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.StatusBarUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.buttons.IconButtonView +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.dylanc.loadingstateview.BaseToolbarViewDelegate +import com.dylanc.loadingstateview.NavBtnType +import com.dylanc.loadingstateview.ToolbarConfig +import com.dylanc.loadingstateview.toolbarExtras + +var ToolbarConfig.titleTextColorToken: Int? by toolbarExtras() +var ToolbarConfig.titleTextColor: Int? by toolbarExtras() +var ToolbarConfig.titleTextAlpha: Float? by toolbarExtras() + +/** + * 整个导航栏相关 + */ +var ToolbarConfig.navBgColorToken: Int? by toolbarExtras() +var ToolbarConfig.navBgColor: Int? by toolbarExtras() +var ToolbarConfig.navBgAlpha: Float? by toolbarExtras() //一般和isFull = true 配合使用,因为只有当navbar和content布局重叠时,才需要滑动渐隐导航栏 +var ToolbarConfig.navIsShow: Boolean? by toolbarExtras() + +var ToolbarConfig.confirmEnabled: Boolean? by toolbarExtras() +var ToolbarConfig.isFull: Boolean? by toolbarExtras() +var ToolbarConfig.confirmContent: String? by toolbarExtras() +var ToolbarConfig.contractUSEnabled: Boolean? by toolbarExtras() + +/** + * 返回键相关 + */ +var ToolbarConfig.navBackColorToken: Int? by toolbarExtras() +var ToolbarConfig.navBackColor: Int? by toolbarExtras() +var ToolbarConfig.onNavBackClick: (() -> Unit)? by toolbarExtras() +var ToolbarConfig.onNavBackIconToken: Int? by toolbarExtras() + +var ToolbarConfig.iconButtonType: IconButtonType? by toolbarExtras() + + +enum class IconButtonType { + ON_PIC, NORMAL +} + +/** + * 公共导航栏 + */ +class ToolbarViewDelegate : BaseToolbarViewDelegate() { + private lateinit var binding: LayoutToolbarBinding + private var context: Context? = null + + override fun onCreateToolbar(inflater: LayoutInflater, parent: ViewGroup): View { + context = parent.context + binding = LayoutToolbarBinding.inflate(inflater, parent, false) + return binding.root + } + + override fun onBindToolbar(config: ToolbarConfig) { + binding.apply { + val expendSize = 4.dp + tvTitle.text = config.title?.ifEmpty { " " } ?: " " + context?.run { + if (config.isFull == true) { + root.tag = "isFull" + (context?.findActivityContext() as? Activity)?.let { + StatusBarUtils.setTransparent(it) + navBg.setSize(height = it.getNavHeight()) + } + navBg.alpha = 0f + tvTitle.alpha = 0f + } + //设置导航栏是否显示 + config.navIsShow?.let { root.isVisible = it } + navBg.setBackgroundColor(getBgColor(config.colorType)) + //设置导航栏颜色 + config.navBgColorToken?.let { navBg.setBackgroundColor(handleUIToken(it)?.color ?: 0) } + config.navBgColor?.let { navBg.setBackgroundColor(it) } + //设置导航栏透明度 + config.navBgAlpha?.let { navBg.alpha = it } + + //设置标题颜色 + config.titleTextColorToken?.let { tvTitle.setTextColor(handleUIToken(it)?.color ?: 0) } + config.titleTextColor?.let { tvTitle.setTextColor(it) } + //设置标题透明度 + config.titleTextAlpha?.let { tvTitle.alpha = it } + + //设置返回按钮颜色 + config.navBackColorToken?.let { navBack.changeTextColor { textUIColorToken = getString(it) } } + config.navBackColor?.let { navBack.setTextColor(it) } + + //设置confirm按钮是否可点 + config.confirmEnabled?.let { rightConfirmBtn.isEnabled = it } + //设置confirm按钮的文字 + config.confirmContent?.let { rightConfirmBtn.text = it } + //设置contract us按钮是否可点 + config.contractUSEnabled?.let { contractUsBtn.isEnabled = it } + + //设置iconfont按钮样式 + when (config.iconButtonType) { + IconButtonType.ON_PIC -> { + navBack.setButtonStyle(buttonName = IconButtonView.NavButton_OnPic) + rightIconBtn1.setButtonStyle(buttonName = IconButtonView.NavButton_OnPic) + rightIconBtn2.setButtonStyle(buttonName = IconButtonView.NavButton_OnPic) + } + + else -> { + navBack.setButtonStyle(buttonName = IconButtonView.NavButton) + rightIconBtn1.setButtonStyle(buttonName = IconButtonView.NavButton) + rightIconBtn2.setButtonStyle(buttonName = IconButtonView.NavButton) + } + } + + navBack.expand(expendSize, expendSize) + } + + val setBackClick = { + setOnClick(navBack) { + if (config.onNavBackClick != null) { + config.onNavBackClick?.invoke() + } else { + (context.findActivityContext() as? Activity)?.onBackPressed() + } + } + } + + when (config.navBtnType) { + //navBack是返回按钮 + NavBtnType.BACK -> { + navBack.setText(R.string.icon_arrow_left_border) + setBackClick.invoke() + navBack.isVisible = true + } + //navBack是关闭按钮 + NavBtnType.ClOSE -> { + navBack.setText(R.string.icon_close) + setBackClick.invoke() + navBack.isVisible = true + } + + //navBack是向下关闭按钮 + NavBtnType.DOWN -> { + navBack.setText(R.string.icon_arrow_down_border) + setBackClick.invoke() + navBack.isVisible = true + } + + //navBack是自定义按钮 + NavBtnType.CUSTOM -> { + if (config.onNavBackIconToken != null) { + navBack.setText(config.onNavBackIconToken!!) + navBack.isVisible = true + setBackClick.invoke() + } else { + navBack.isInvisible = true + } + } + + NavBtnType.INVISIBLE -> { + navBack.isInvisible = true + } + + NavBtnType.NONE -> { + navBack.isVisible = false + } + } + + with(contractUsBtn) { + if (config.contractUsBtnText != null) { + isVisible = true + val expandX = 24.dp + val expandY = 10.dp + expand(expandX, expandY) + if (config.contractUsBtnText != null) { + setText(config.contractUsBtnText ?: 0) + } + setOnClick(this) { + config.onContractUsBtnBlock?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + with(rightConfirmBtn) { + if (config.showConfirmBtn == true) { + isVisible = true + setOnClick(this) { + config.onConfirmBtnBlock?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + with(rightIconBtn1) { + if (config.showRightIconBtn1 != null) { + isVisible = true + setText(config.showRightIconBtn1!!) + expand(expendSize, expendSize) + setOnClick(this) { + config.onRightIconBtnBlock1?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + + with(rightIconBtn2) { + if (config.showRightIconBtn2 != null) { + isVisible = true + setText(config.showRightIconBtn2!!) + changeTextColor { + textUIColorToken = if (config.rightIconColorBtn2 != null) { + context.getString(config.rightIconColorBtn2!!) + } else { + context.getString(R.string.color_txt_primary_normal) + } + } + + expand(expendSize, expendSize) + setOnClick(this) { + config.onRightIconBtnBlock2?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + with(rightBtn) { + if (config.showRightBtn != null) { + isVisible = true + setText(config.showRightBtn!!) + setOnClick(this) { + config.onRightBtnBlock?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt new file mode 100644 index 0000000..0420e5d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt @@ -0,0 +1,47 @@ +package com.remax.visualnovel.app.di + + +import com.remax.visualnovel.api.factory.ServiceFactory +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.api.service.DictService +import com.remax.visualnovel.api.service.LoginService +import com.remax.visualnovel.api.service.MessageService +import com.remax.visualnovel.api.service.UserService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * hilt注入service请求 + */ +@Module +@InstallIn(SingletonComponent::class) +object ApiServiceModule { + + @Singleton + @Provides + fun userService() = create() + + @Singleton + @Provides + fun loginService() = create() + + @Singleton + @Provides + fun messageService() = create() + + @Singleton + @Provides + fun dictService() = create() + + @Singleton + @Provides + fun bookService() = create() + + + private inline fun create(): T { + return ServiceFactory.createService() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt new file mode 100644 index 0000000..057ce13 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt @@ -0,0 +1,31 @@ +package com.remax.visualnovel.app.di + +import androidx.lifecycle.ViewModelProvider +import com.remax.visualnovel.app.base.app.AppViewModelFactory +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel +import com.remax.visualnovel.app.viewmodel.AppIMViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * [AppIMViewModel] 提供者 + */ +@InstallIn(SingletonComponent::class) +@Module +object ViewModelModule { + + @Singleton + @Provides + fun provideAppIMViewModel(factory: AppViewModelFactory) = + ViewModelProvider(CommonApplicationProxy.viewModelStore, factory)[AppIMViewModel::class.java] + + @Singleton + @Provides + fun provideAppGlobalViewModel(factory: AppViewModelFactory) = + ViewModelProvider(CommonApplicationProxy.viewModelStore, factory)[AppGlobalViewModel::class.java] + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt new file mode 100644 index 0000000..75eae27 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.app.initializer + +/** + * Created by HJW on 2023/5/11 + * + * 启动类型 + */ +enum class AppInitializerStartType { + /** + * 串行执行 + */ + TYPE_SERIES, + + /** + * 并发执行 + */ + TYPE_PARALLEL, +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt new file mode 100644 index 0000000..14a7989 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.app.initializer + +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority + + +/** + * Created by HJW on 2023/5/11 + * 后续都需要依赖此框架 + */ +interface AppInitializers { + /** + * 初始化代码 + */ + fun init() + + /** + * @return 初始化类型 + */ + fun getStartType(): AppInitializerStartType = AppInitializerStartType.TYPE_SERIES + + /** + * TYPE_SERIES 类型时,权重越大,越先执行 + */ + fun widget(): Int = AppInitializerPriority.SERIES_1.priority +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt new file mode 100644 index 0000000..25cbd23 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.app.initializer + +import android.app.Application +import android.util.Log +import com.remax.visualnovel.app.initializer.di.AppInitializerEntryPoint +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Created by HJW on 2023/5/11 + */ +class AppInitializersProvider @Inject constructor(private val application: Application) { + + private val TAG = "AppInitializersProvider" + + private val initializers: Set by lazy { + EntryPointAccessors.fromApplication(application, AppInitializerEntryPoint::class.java) + .getAppInitializers() + } + + fun startInit() { + val seriesList = initializers.filter { + it.getStartType() == AppInitializerStartType.TYPE_SERIES + }.sortedByDescending { it.widget() } + val parallelList = + initializers.filter { it.getStartType() == AppInitializerStartType.TYPE_PARALLEL } + Log.d(TAG, "AppInitializersProvider 开始执行 并行") + parallelList.parallelStream().forEach { + MainScope().launch { + Log.d(TAG, "AppInitializersProvider $it 初始化模块协程开始执行: ${this.coroutineContext}") + it.init() + } + } + Log.d(TAG, "AppInitializersProvider 结束执行 并行") + seriesList.forEach { + it.init() + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt new file mode 100644 index 0000000..6ccd258 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.app.initializer.di + + +import com.remax.visualnovel.app.initializer.AppInitializers +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Created by HJW on 2023/5/11 + * 启动框架容器注解 + */ + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AppInitializerEntryPoint { + fun getAppInitializers(): Set +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt new file mode 100644 index 0000000..b53acda --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt @@ -0,0 +1,19 @@ +package com.remax.visualnovel.app.initializer.di + +/** + * Created by HJW on 2023/6/12 + */ +enum class AppInitializerPriority(val priority: Int) { + /** + * 并发,无优先级 + */ + PARALLEL(1), + + /** + * 串行,数字越大,优先级越高 + */ + SERIES_1(1), + SERIES_2(2), + SERIES_3(3), + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt new file mode 100644 index 0000000..d7f973f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.app.initializer.di + +import android.app.Application +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.impl.ActivityLifecycleInitializer +import com.remax.visualnovel.app.initializer.impl.LocalDataInitializer +import com.remax.visualnovel.app.initializer.impl.RouterInitializer +import com.remax.visualnovel.app.initializer.impl.SystemInitializer +import com.remax.visualnovel.app.initializer.impl.ThirdInitializer +import com.remax.visualnovel.app.initializer.impl.UserInitializer +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel +import com.remax.visualnovel.app.viewmodel.AppIMViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Created by HJW on 2023/5/11 + * + * 添加容器注入 + */ +@Module +@InstallIn(SingletonComponent::class) +object AppInitializersModule { + + @Provides + fun provideAppInitializers(application: Application, + appIMViewModel: AppIMViewModel, + appGlobalViewModel: AppGlobalViewModel + ): Set = setOf( + // FirebaseInitializer(application), TODO- add firebase support later + UserInitializer(application), + //JsInitializer(application), + LocalDataInitializer(application), + RouterInitializer(application), + ThirdInitializer(application), + ActivityLifecycleInitializer(application, appIMViewModel), + SystemInitializer(application, appGlobalViewModel) + ) + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt new file mode 100644 index 0000000..f7c9965 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority +import com.remax.visualnovel.utils.datastore.IDataStoreOwner +import com.tencent.mmkv.MMKV + +/** + * Created by HJW on 2023/5/11 + * 本地数据保存相关初始化 + */ +class LocalDataInitializer(val application: Application) : AppInitializers { + + override fun init() { + //初始化mmkv + val dir = application.filesDir.absolutePath + "/mmkv_epal" + MMKV.initialize(application, dir) + //初始化datastore + IDataStoreOwner.application = application + } + + override fun widget(): Int { + return AppInitializerPriority.SERIES_3.priority + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt new file mode 100644 index 0000000..d5d39eb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import com.alibaba.android.arouter.launcher.ARouter +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority + +/** + * Created by HJW on 2023/5/11 + * 路由相关初始化 + */ +class RouterInitializer(val application: Application) : AppInitializers { + + override fun init() { + if (BuildConfig.DEBUG) { + ARouter.openLog() + ARouter.openDebug() + } + ARouter.init(application) + } + + override fun widget(): Int { + return AppInitializerPriority.SERIES_3.priority + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt new file mode 100644 index 0000000..c7223be --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt @@ -0,0 +1,47 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import android.os.Build +import android.webkit.WebView +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ProcessLifecycleOwner +import com.remax.visualnovel.app.ProcessLifecycleObserver +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel + +/** + * Created by HJW on 2023/5/11 + * 系统相关初始化 + */ +class SystemInitializer(val application: Application, val appGlobalViewModel: AppGlobalViewModel) : AppInitializers { + + override fun init() { + //监听application生命周期 + ProcessLifecycleOwner.get().lifecycle.addObserver(ProcessLifecycleObserver) + ProcessLifecycleObserver.setAppGlobalViewModel(appGlobalViewModel) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + //启用矢量图兼容 + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + var processName = "" + (application.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager) + ?.runningAppProcesses + ?.asSequence() + ?.forEach { processInfo -> + if (processInfo.pid == android.os.Process.myPid()) { + processName = processInfo.processName + } + } + if (processName != application.packageName) { + WebView.setDataDirectorySuffix(processName) + } + } + } + + override fun widget(): Int { + return AppInitializerPriority.SERIES_2.priority + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt new file mode 100644 index 0000000..a77a672 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt @@ -0,0 +1,153 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import android.content.Context +import android.net.http.HttpResponseCache +import android.os.Environment +import android.text.TextUtils +import com.chad.library.adapter.base.module.LoadMoreModuleConfig +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.app.delegate.ToolbarViewDelegate +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.widget.CustomLoadMoreView +import com.remax.visualnovel.app.widget.LoadMoreFooter +import com.remax.visualnovel.app.widget.RefreshHeader +import com.remax.visualnovel.utils.NotLoggingTree +import com.dylanc.loadingstateview.LoadingStateView +import com.github.boybeak.skbglobal.SoftKeyboardGlobal +import com.github.sahasbhop.apngview.ApngImageLoader +import com.lzf.easyfloat.EasyFloat +import com.pengxr.modular.eventbus.facade.launcher.IEventListener +import com.pengxr.modular.eventbus.facade.launcher.ModularEventBus +import com.pengxr.modular.eventbus.facade.template.BaseEvent +import com.scwang.smart.refresh.layout.SmartRefreshLayout +import timber.log.Timber +import java.io.File +import java.io.IOException + +/** + * Created by HJW on 2023/5/11 + * 三方库相关初始化 + */ +class ThirdInitializer(val application: Application) : AppInitializers { + + override fun init() { + ApngImageLoader.getInstance().init(application) + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant(NotLoggingTree()) + } + SoftKeyboardGlobal.install(application, false) + + LoadingStateView.setViewDelegatePool { + register(ToolbarViewDelegate()) + } + /** + * SVGA http缓存 + */ + val cacheDir = File(application.cacheDir, "http") + HttpResponseCache.install(cacheDir, 1024 * 1024 * 200) + EasyFloat.init(application, BuildConfig.DEBUG) + + LoadMoreModuleConfig.defLoadMoreView = CustomLoadMoreView() + //设置全局默认配置(优先级最低,会被其他设置覆盖) + SmartRefreshLayout.setDefaultRefreshInitializer { _, layout -> //全局设置(优先级最低) + layout.setEnableLoadMore(false) + layout.setEnableAutoLoadMore(false) + layout.setEnableOverScrollDrag(true) + layout.setEnableOverScrollBounce(true) + layout.setEnableLoadMoreWhenContentNotFull(false) + layout.setEnableScrollContentWhenRefreshed(true) + layout.setHeaderMaxDragRate(3.0f) + } + SmartRefreshLayout.setDefaultRefreshHeaderCreator { context, layout -> //全局设置header + layout.setEnableHeaderTranslationContent(true) + RefreshHeader(context) + } + SmartRefreshLayout.setDefaultRefreshFooterCreator { context, layout -> //全局设置footer + layout.setEnableFooterTranslationContent(true) + LoadMoreFooter(context) + } + + ModularEventBus.debug(BuildConfig.DEBUG) + .throwNullEventException(false) + .setEventListener(object : IEventListener { + override fun onEventPost(eventName: String, event: BaseEvent, data: T?) { + Timber.d("ModularEventBus发送事件 eventName: $eventName, event=$event data=$data") + } + }) + + //NIMClient.initV2(application, getSDKOptions(application)) + initLocalNimString() + } + + /*private fun getSDKOptions(context: Context): SDKOptions { + val options = SDKOptions() + with(options) { + this.appKey = "2d6abc076f434fc52320c7118de5fead" + enableV2CloudConversation = true + //在线多端同步未读数 + sessionReadAck = true + // 采用异步加载SDK + asyncInitSDK = true + //禁止后台进程唤醒UI进程 + disableAwake = true + useXLog = true + enableBackOffReconnectStrategy = true + checkManifestConfig = BuildConfig.DEBUG + loginCustomTag = ClientType.Android.toString() + notifyStickTopSession = true + mixPushConfig = MixPushConfig().apply { + fcmCertificateName = "VisualNovel" + } + if (BuildConfig.DEBUG) { + sdkStorageRootPath = getAppCacheDir(context) + "/nim" + } + } + return options + }*/ + + /** + * 配置 APP 保存图片/语音/文件/log等数据的目录 + * 这里示例用SD卡的应用扩展存储目录 + */ + private fun getAppCacheDir(context: Context): String? { + var storageRootPath: String? = null + try { + // SD卡应用扩展存储区(APP卸载后,该目录下被清除,用户也可以在设置界面中手动清除),请根据APP对数据缓存的重要性及生命周期来决定是否采用此缓存目录. + // 该存储区在API 19以上不需要写权限,即可配置 + if (context.externalCacheDir != null) { + storageRootPath = context.externalCacheDir!!.canonicalPath + } + } catch (e: IOException) { + e.printStackTrace() + } + if (TextUtils.isEmpty(storageRootPath)) { + // SD卡应用公共存储区(APP卸载后,该目录不会被清除,下载安装APP后,缓存数据依然可以被加载。SDK默认使用此目录),该存储区域需要写权限! + storageRootPath = Environment.getExternalStorageDirectory().toString() + "/" + context.packageName + } + return storageRootPath + } + + private fun initLocalNimString() { + /*NimStrings.DEFAULT.apply { + status_bar_multi_messages_incoming = application.getString(R.string.status_bar_multi_messages_incoming) + status_bar_ticker_text = application.getString(R.string.status_bar_ticker_text) + status_bar_image_message = application.getString(R.string.status_bar_image_message) + status_bar_audio_message = application.getString(R.string.status_bar_audio_message) + status_bar_video_message = application.getString(R.string.status_bar_video_message) + status_bar_file_message = application.getString(R.string.status_bar_file_message) + status_bar_location_message = application.getString(R.string.status_bar_location_message) + status_bar_notification_message = application.getString(R.string.status_bar_notification_message) + status_bar_avchat_message = application.getString(R.string.status_bar_avchat_message) + status_bar_tip_message = application.getString(R.string.status_bar_tip_message) + status_bar_custom_message = application.getString(R.string.status_bar_custom_message) + status_bar_unsupported_message = application.getString(R.string.status_bar_unsupported_message) + status_bar_hidden_message_content = application.getString(R.string.status_bar_hidden_message_content) + status_bar_hidden_message_title = application.getString(R.string.status_bar_hidden_message_title) + }*/ + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt new file mode 100644 index 0000000..a441a4a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt @@ -0,0 +1,20 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.app.initializer.AppInitializers + +/** + * Created by HJW on 2023/5/11 + * user相关初始化 + */ +class UserInitializer(val application: Application) : AppInitializers { + + /** + * 初始化必须放在本地数据存储框架初始化之后才行 + */ + override fun init() { + LoginManager.init() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt new file mode 100644 index 0000000..4053cb3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt @@ -0,0 +1,29 @@ +package com.remax.visualnovel.app.initializer.utils + +import com.google.android.gms.tasks.Task +import com.google.firebase.messaging.FirebaseMessaging +import timber.log.Timber + +/** + * Created by HJW on 2022/10/18 + */ +object FirebaseHelper { + + fun getToken(tokenCallback: ((String?) -> Unit)? = null) { + FirebaseMessaging.getInstance().token.addOnCompleteListener { task: Task -> + try { + Timber.d("firebase CompleteListener isSuccessful : %s", task.isSuccessful) + if (task.isSuccessful && task.result != null) { + val token = task.result + Timber.d("firebase token : %s", token) + tokenCallback?.invoke(token) + } else { + tokenCallback?.invoke(null) + } + } catch (e: Exception) { + Timber.d("firebase token exception: %s", e.localizedMessage) + tokenCallback?.invoke(null) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt new file mode 100644 index 0000000..caea749 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.repository.api.MessageRepository +import com.remax.visualnovel.repository.api.UserRepository + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Created by HJW on 2022/11/1 + */ +@HiltViewModel +class AppGlobalViewModel @Inject constructor( + application: Application, + private val userRepository: UserRepository, + private val messageRepository: MessageRepository +) : AndroidViewModel(application) { + + + + /** + * 更新firebase设备码到后端,推送用 + */ + fun updateTerminal(terminalCode: String?) { + if (LoginManager.isLogin) { + viewModelScope.launch { +// userRepository.updateTerminal(terminalCode) + } + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt new file mode 100644 index 0000000..4ce3df3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt @@ -0,0 +1,32 @@ +package com.remax.visualnovel.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.gson.Gson +import com.remax.visualnovel.utils.TimeUtils + +/*import com.netease.nimlib.sdk.NIMClient +import com.netease.nimlib.sdk.Observer +import com.netease.nimlib.sdk.StatusCode +import com.netease.nimlib.sdk.msg.MsgServiceObserve +import com.netease.nimlib.sdk.msg.constant.MsgTypeEnum +import com.netease.nimlib.sdk.msg.model.IMMessage*/ +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.abs + +/** + * Created by HJW on 2022/10/18 + * [Application]生命周期内的[AndroidViewModel] + */ +@HiltViewModel +class AppIMViewModel @Inject constructor(application: Application) : AndroidViewModel(application), + DefaultLifecycleObserver { +} + + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt new file mode 100644 index 0000000..3aa6722 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt @@ -0,0 +1,16 @@ +package com.remax.visualnovel.app.viewmodel.base + +import com.remax.visualnovel.app.viewmodel.base.UserViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 应用相关viewmodel的父类 + */ +@HiltViewModel +open class AppViewModel @Inject constructor() : UserViewModel() { + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt new file mode 100644 index 0000000..0b438ed --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt @@ -0,0 +1,16 @@ +package com.remax.visualnovel.app.viewmodel.base + +import androidx.lifecycle.ViewModel + +/** + * Created by HJW on 2022/10/27 + */ +open class BaseViewModel : ViewModel() { + + open fun onStart() { + } + + open fun onDestroy() { + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt new file mode 100644 index 0000000..cf9ab9c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt @@ -0,0 +1,28 @@ +package com.remax.visualnovel.app.viewmodel.base + +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 钱包。支付相关父类 + */ +@HiltViewModel +open class PayViewModel @Inject constructor() : BaseViewModel() { + +// @Inject +// lateinit var walletRepository: WalletRepository +// +// @Inject +// lateinit var payRepository: PayRepository +// +// private val _walletFlow = MutableSharedFlow>() +// val walletFlow = _walletFlow.asSharedFlow() +// +// suspend fun getMyWallet(): Response { +// return walletRepository.getMyWallet().apply { _walletFlow.emit(this) } +// } +// +// suspend fun checkOut(tradeNo: String) = payRepository.checkOut(tradeNo) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt new file mode 100644 index 0000000..4fae626 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt @@ -0,0 +1,72 @@ +package com.remax.visualnovel.app.viewmodel.base + + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.entity.response.base.ApiFailedResponse +import com.remax.visualnovel.entity.response.base.ApiSuccessResponse +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.LoginRepository +import com.remax.visualnovel.repository.api.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * User相关viewmodel的父类 + */ +@HiltViewModel +open class UserViewModel @Inject constructor() : BaseViewModel() { + + @Inject + lateinit var userRepository: UserRepository + + private val _userInfoFlow = MutableStateFlow>(Response()) + val userInfoFlow: StateFlow> = _userInfoFlow.asStateFlow() + + suspend fun getMyBaseInfo(): Response { + return userRepository.getMyBaseInfo().also { _userInfoFlow.value = it } + } + + /** + * 公共返回,很多接口操作后需要再次请求用户数据 + */ + protected suspend fun returnUserResponse(response: Response<*>): Response { + return if (response.isApiSuccess) { + getMyBaseInfo() + } else { + ApiFailedResponse(response.errorCode, response.errorMsg) + } + } + + //suspend fun getNimInfo() = userRepository.getNimInfo() + + @Inject + lateinit var loginRepository: LoginRepository + + /** + * 公共返回,检查nickname是否存在 + */ + suspend fun checkNickname( + nickName: String?, + exUserId: String? = null, + apiCall: (suspend () -> Response)? = null + ): Response { + val checkRes = loginRepository.checkUserNickname(nickName, exUserId) + return if (checkRes.isApiSuccess) { + if (checkRes.data == true) { + ApiFailedResponse("", CommonApplicationProxy.application.getString(R.string.nickname_exist_error)) + } else { + apiCall?.invoke() ?: ApiSuccessResponse() + } + } else { + ApiFailedResponse(checkRes.errorCode, checkRes.errorMsg) + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt new file mode 100644 index 0000000..daeebd0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt @@ -0,0 +1,40 @@ +package com.remax.visualnovel.app.widget + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.chad.library.adapter.base.loadmore.BaseLoadMoreView +import com.chad.library.adapter.base.viewholder.BaseViewHolder +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.setSize + +/** + * Created by HJW on 2020/9/18 + */ +class CustomLoadMoreView(private val showLoading: Boolean = true) : BaseLoadMoreView() { + + override fun getLoadComplete(holder: BaseViewHolder): View { + return holder.getView(R.id.load_more_load_complete_view) + } + + override fun getLoadEndView(holder: BaseViewHolder): View { + return holder.getView(R.id.load_more_load_end_view) + } + + override fun getLoadFailView(holder: BaseViewHolder): View { + return holder.getView(R.id.load_more_load_fail_view) + } + + override fun getLoadingView(holder: BaseViewHolder): View { + val loadingView = holder.getView(R.id.load_more_loading_view) + if (!showLoading) { + loadingView.setSize(height = 0) + } + return loadingView + } + + override fun getRootView(parent: ViewGroup): View { + // 整个 LoadMore 布局 + return LayoutInflater.from(parent.context).inflate(R.layout.view_load_more_common, parent, false) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt new file mode 100644 index 0000000..25f544a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt @@ -0,0 +1,113 @@ +package com.remax.visualnovel.app.widget + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.chad.library.adapter.base.BaseQuickAdapter +import com.drake.brv.PageRefreshLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.configs.NovelApplication +import com.remax.visualnovel.databinding.LayoutEmptyBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.utils.spannablex.utils.dp + + +fun PageRefreshLayout.setEmptyText( + @StringRes emptyTextResId: Int = 0, + @DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, + topMargin: Int? = null, +) { + emptyLayout = R.layout.layout_empty + stateEnabled = true + onEmpty { + LayoutEmptyBinding.bind(this).apply { + if (emptyIcon != 0) { + ivEmpty.setImageResource(emptyIcon) + ivEmpty.isVisible = true + } + if (emptyTextResId != 0) { + tvEmpty.setText(emptyTextResId) + tvEmpty.isVisible = true + } + topMargin?.let { + root.run { + setPaddingRelative(paddingStart, topMargin.dp, paddingEnd, paddingBottom) + } + } + } + } +} + +fun BaseQuickAdapter<*, *>.setMyEmptyView( + @StringRes emptyTextResId: Int = 0, + @DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, + topMargin: Int? = null, + @StringRes btnText: Int = 0, + btnInvoke: (() -> Unit)? = null +) { + NovelApplication.getCurrentActivity()?.let { activity -> + setEmptyView( + EmptyView(activity).createEmptyView(emptyTextResId, emptyIcon, topMargin, btnText, btnInvoke) + ) + } +} + +class EmptyView(private val context: Context) { + + private val viewBind: LayoutEmptyBinding = LayoutEmptyBinding.inflate(LayoutInflater.from(context)) + + fun createEmptyView( + @StringRes emptyTextResId: Int = 0, + @DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, + topMargin: Int? = null, + @StringRes btnText: Int = 0, + btnInvoke: (() -> Unit)? = null + ): View { + setEmptyView(emptyIcon, emptyTextResId) + topMargin?.let { + setMarginTop(it.dp) + } + if (btnText != 0) { + setBtn(btnText, btnInvoke) + } + return viewBind.root + } + + private fun setEmptyView(@DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, @StringRes emptyTextResId: Int = 0): EmptyView { + with(viewBind) { + ivEmpty.isVisible = false + emptyIcon.takeIf { it != 0 }?.let { + ivEmpty.setImageResource(it) + ivEmpty.isVisible = true + } + tvEmpty.isVisible = false + emptyTextResId.takeIf { it != 0 }?.let { + tvEmpty.text = context.getString(emptyTextResId) + tvEmpty.isVisible = true + } + } + return this + } + + private fun setBtn(@StringRes btnText: Int, btnInvoke: (() -> Unit)? = null): EmptyView { + with(viewBind.btnEmptyMessage) { + isVisible = true + text = context.getString(btnText) + setOnClick(this) { + btnInvoke?.invoke() + } + } + return this + } + + private fun setMarginTop(topMargin: Int): EmptyView { + viewBind.root.run { + setPaddingRelative(paddingStart, topMargin, paddingEnd, paddingBottom) + } + return this + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java new file mode 100644 index 0000000..e3a657c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java @@ -0,0 +1,92 @@ +package com.remax.visualnovel.app.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.remax.visualnovel.R; +import com.scwang.smart.refresh.layout.api.RefreshKernel; +import com.scwang.smart.refresh.layout.api.RefreshLayout; +import com.scwang.smart.refresh.layout.constant.RefreshState; +import com.scwang.smart.refresh.layout.constant.SpinnerStyle; +import com.scwang.smart.refresh.layout.util.SmartUtil; + +public class LoadMoreFooter extends LinearLayout implements com.scwang.smart.refresh.layout.api.RefreshFooter { + + public LoadMoreFooter(Context context) { + this(context, null); + } + + public LoadMoreFooter(Context context, @Nullable AttributeSet attrs) { + super(context, attrs, 0); + View.inflate(context, R.layout.load_more_loading_view, this); + setMinimumHeight(SmartUtil.dp2px(50)); + } + + @Override + public boolean setNoMoreData(boolean noMoreData) { + this.setVisibility(noMoreData ? View.GONE : View.VISIBLE); + return true; + } + + @NonNull + @Override + public View getView() { + return this; + } + + @NonNull + @Override + public SpinnerStyle getSpinnerStyle() { + return SpinnerStyle.Translate; + } + + @Override + public void setPrimaryColors(int... colors) { + + } + + @Override + public void onInitialized(@NonNull RefreshKernel kernel, int height, int maxDragHeight) { + + } + + @Override + public void onMoving(boolean isDragging, float percent, int offset, int height, int maxDragHeight) { + + } + + @Override + public void onReleased(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + + } + + @Override + public void onStartAnimator(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + + } + + @Override + public int onFinish(@NonNull RefreshLayout refreshLayout, boolean success) { + return 0; + } + + @Override + public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) { + + } + + @Override + public boolean isSupportHorizontalDrag() { + return false; + } + + @Override + public void onStateChanged(@NonNull RefreshLayout refreshLayout, @NonNull RefreshState oldState, @NonNull RefreshState newState) { + + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt new file mode 100644 index 0000000..0f16814 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt @@ -0,0 +1,40 @@ +package com.remax.visualnovel.app.widget + +import android.content.Context +import com.remax.visualnovel.databinding.DialogLoadingBinding +import com.remax.visualnovel.widget.dialoglib.LBindingDialog + +class LoadingDialog { + + private lateinit var dialog: LBindingDialog + private var context: Context? = null + + fun build(context: Context): LoadingDialog { + this.context = context + dialog = LBindingDialog(context,DialogLoadingBinding::inflate) + dialog.with() + .setCenter() + .setBgRadius(4) + .setWidth(80) + .setHeight(80) + dialog.setCancelable(false) + dialog.setCanceledOnTouchOutside(false) + return this + } + + fun getDialog(): LBindingDialog { + return dialog + } + + fun show() { + if (this::dialog.isInitialized) { + dialog.show() + } + } + + fun dismiss() { + if (this::dialog.isInitialized) { + dialog.dismiss() + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java new file mode 100644 index 0000000..6f3f810 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java @@ -0,0 +1,109 @@ +package com.remax.visualnovel.app.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieAnimationView; +import com.remax.visualnovel.R; +import com.scwang.smart.refresh.layout.api.RefreshKernel; +import com.scwang.smart.refresh.layout.api.RefreshLayout; +import com.scwang.smart.refresh.layout.constant.RefreshState; +import com.scwang.smart.refresh.layout.constant.SpinnerStyle; +import com.scwang.smart.refresh.layout.util.SmartUtil; + +public class RefreshHeader extends LinearLayout implements com.scwang.smart.refresh.layout.api.RefreshHeader { + private final LottieAnimationView mProgressView;//刷新动画视图 + + public RefreshHeader(Context context) { + this(context, null); + } + + public RefreshHeader(Context context, @Nullable AttributeSet attrs) { + super(context, attrs, 0); + setGravity(Gravity.CENTER); + mProgressView = new LottieAnimationView(context); + mProgressView.setAnimation(R.raw.single_ring); + mProgressView.setSafeMode(true); + mProgressView.setRepeatCount(-1); + addView(mProgressView, SmartUtil.dp2px(26), SmartUtil.dp2px(26)); + setMinimumHeight(SmartUtil.dp2px(30)); + } + + @NonNull + @Override + public View getView() { + return this; + } + + @NonNull + @Override + public SpinnerStyle getSpinnerStyle() { + return SpinnerStyle.Translate; + } + + @Override + public void setPrimaryColors(int... colors) { + + } + + @Override + public void onInitialized(@NonNull RefreshKernel kernel, int height, int maxDragHeight) { + + } + + @Override + public void onMoving(boolean isDragging, float percent, int offset, int height, int maxDragHeight) { + if (isDragging){ + float currPer = percent / 3.0f; + if (currPer > 1.0f) currPer = 1.0f; + float frameF = mProgressView.getMaxFrame() * currPer; + int frame = (int) (frameF); + mProgressView.setFrame(frame); + mProgressView.invalidate(); + } + } + + @Override + public void onReleased(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + mProgressView.setRepeatCount(-1); + mProgressView.setProgress(0f); + mProgressView.playAnimation(); + } + + @Override + public void onStartAnimator(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + + } + + @Override + public int onFinish(@NonNull RefreshLayout refreshLayout, boolean success) { + mProgressView.postDelayed(new Runnable() { + @Override + public void run() { + mProgressView.cancelAnimation(); + } + },1100); + return 1000; + } + + @Override + public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) { + + } + + @Override + public boolean isSupportHorizontalDrag() { + return false; + } + + @Override + public void onStateChanged(@NonNull RefreshLayout refreshLayout, @NonNull RefreshState oldState, @NonNull RefreshState newState) { + + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt new file mode 100644 index 0000000..a3e633c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt @@ -0,0 +1,147 @@ +package com.remax.visualnovel.app.widget.tips + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.PopupWindow +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PopwindowFeedsBackTipsBinding +import com.remax.visualnovel.entity.request.AIFeedback +import com.remax.visualnovel.extension.setOnClick + +class TipsFeedbackWindow { + + private var popupWindow: PopupWindow? = null + + fun build( + context: Context, + optType: Int, + clickCallback: (Int, optType: Int) -> Unit + ): TipsFeedbackWindow { + val view = LayoutInflater.from(context).inflate(R.layout.popwindow_feeds_back_tips, null) + popupWindow = PopupWindow(context).apply { + isFocusable = true + contentView = view + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable()) + } + var currOptType = optType + + PopwindowFeedsBackTipsBinding.bind(view).run { + fun changeOptType() { + val likeColorToken = context.getString(R.string.color_primary_variant_normal) + val normalColorToken = context.getString(R.string.color_txt_primary_normal) + val iconSize = 20 + val iconPadding = 16 + + fun updateLikeDislikeIcons( + likeIconRes: String, + likeColor: String, + dislikeIconRes: String, + dislikeColor: String, + ) { + like.setIconFontDrawable( + likeIconRes, + iconColorToken = likeColor, + iconSize = iconSize, + iconPadding = iconPadding + ) + dislike.setIconFontDrawable( + dislikeIconRes, + iconColorToken = dislikeColor, + iconSize = iconSize, + iconPadding = iconPadding + ) + } + + when (currOptType) { + AIFeedback.LIKE -> updateLikeDislikeIcons( + context.getString(R.string.icon_post_recommend_fill), + likeColorToken, + context.getString(R.string.icon_post_notrecommend), + normalColorToken + ) + + AIFeedback.DISLIKE -> updateLikeDislikeIcons( + context.getString(R.string.icon_post_recommend), + normalColorToken, + context.getString(R.string.icon_post_notrecommend_fill), + likeColorToken + ) + + else -> updateLikeDislikeIcons( + context.getString(R.string.icon_post_recommend), + normalColorToken, + context.getString(R.string.icon_post_notrecommend), + normalColorToken + ) + } + + when (currOptType) { + AIFeedback.LIKE -> { + like.setIconFontDrawable( + context.getString(R.string.icon_post_recommend_fill), + iconColorToken = likeColorToken, + iconSize = iconSize, + iconPadding = iconPadding + ) + dislike.setIconFontDrawable( + context.getString(R.string.icon_post_notrecommend), + iconColorToken = normalColorToken, + iconSize = iconSize, + iconPadding = iconPadding + ) + } + } + } + + changeOptType() + setOnClick(copy, like, dislike) { + when (this) { + copy -> { + clickCallback.invoke(0, currOptType) + popupWindow?.dismiss() + } + + like -> { + currOptType = if (currOptType == AIFeedback.LIKE) AIFeedback.NONE else AIFeedback.LIKE + changeOptType() + clickCallback.invoke(1, currOptType) + } + + dislike -> { + currOptType = if (currOptType == AIFeedback.DISLIKE) AIFeedback.NONE else AIFeedback.DISLIKE + changeOptType() + clickCallback.invoke(2, currOptType) + } + } + } + } + return this + } + + fun showAsDropDown(view: View?) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view) + } + } + + fun showAsDropDown(view: View?, xoff: Int, yoff: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view, xoff, yoff) + } + } + + fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAtLocation(parent, gravity, x, y) + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt new file mode 100644 index 0000000..7a8e0b7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt @@ -0,0 +1,92 @@ +package com.remax.visualnovel.app.widget.tips + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.PopupWindow +import androidx.annotation.StringRes +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.IconFontTextView +import com.remax.visualnovel.widget.uitoken.changeTextStyle + +class TipsMoreWindow { + data class TipsMoreUIData( + @StringRes val titleRes: Int, + @StringRes val iconRes: Int, + ) + + private var popupWindow: PopupWindow? = null + + fun build( + context: Context, + tipsData: List?, + clickCallback: (TipsMoreUIData) -> Unit = {} + ): TipsMoreWindow { + val view = LayoutInflater.from(context).inflate(R.layout.popwindow_btn_tips, null) + popupWindow = PopupWindow(context).apply { + isFocusable = true + contentView = view + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable()) + } + val group = view.findViewById(R.id.group) + group.removeAllViews() + tipsData?.forEach { item -> + val iconView = IconFontTextView(context) + group.addView(iconView) + iconView.apply { + gravity = Gravity.START + setSize(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + setPadding(8.dp, 12.dp, 8.dp, 12.dp) + changeTextStyle { + textUITextToken = context.getString(R.string.txt_label_l) + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + + } + setIconFontDrawable( + startIconFont = context.getString(item.iconRes), + iconColorToken = context.getString(R.string.color_txt_primary_normal), + iconSize = 20, + iconPadding = 16 + ) + setText(item.titleRes) + setOnClick(this) { + popupWindow?.dismiss() + clickCallback(item) + } + } + } + return this + } + + fun showAsDropDown(view: View?) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view) + } + } + + fun showAsDropDown(view: View?, xoff: Int, yoff: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view, xoff, yoff) + } + } + + fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAtLocation(parent, gravity, x, y) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java new file mode 100644 index 0000000..554b442 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java @@ -0,0 +1,76 @@ +package com.remax.visualnovel.app.widget.tips; + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.PopupWindow; +import android.widget.TextView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.extension.ViewExtKt; + + +public class TipsPopWindow { + + private PopupWindow popupWindow; + + public TipsPopWindow build(Context context, String content, int width) { + View view = LayoutInflater.from(context).inflate(R.layout.popwindow_tips, null); + popupWindow = new PopupWindow(context); + popupWindow.setFocusable(true); + popupWindow.setContentView(view); + if (width != ViewGroup.LayoutParams.MATCH_PARENT) { + ViewExtKt.setMargin(view, 0, 0, 0, 0); + TextView tv = view.findViewById(R.id.tvContent); + textWidth = tv.getPaint().measureText(content); + } + popupWindow.setWidth(width); + popupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + popupWindow.setOutsideTouchable(true); + popupWindow.setBackgroundDrawable(new ColorDrawable()); + + TextView tvContent = view.findViewById(R.id.tvContent); + tvContent.setText(content); + return this; + } + + private float textWidth; + + public float getTextWidth() { + return textWidth; + } + + public TipsPopWindow build(Context context, String content) { + return build(context, content, ViewGroup.LayoutParams.MATCH_PARENT); + } + + public TipsPopWindow build(Context context, int resId) { + return build(context, context.getString(resId)); + } + + public void showAsDropDown(View view) { + if (popupWindow.isShowing()) { + popupWindow.dismiss(); + } else { + popupWindow.showAsDropDown(view); + } + } + + public void showAsDropDown(View view, int xoff, int yoff) { + if (popupWindow.isShowing()) { + popupWindow.dismiss(); + } else { + popupWindow.showAsDropDown(view, xoff, yoff); + } + } + + public void showAtLocation(View parent, int gravity, int x, int y) { + if (popupWindow.isShowing()) { + popupWindow.dismiss(); + } else { + popupWindow.showAtLocation(parent, gravity, x, y); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt new file mode 100644 index 0000000..b3b475e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.app.widget.tips + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.StringRes +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.ui.SwitchView + +class TipsSwitchWindow { + private var popupWindow: PopupWindow? = null + + fun build( + context: Context, + @StringRes tips: Int, + isChecked: Boolean = false, + switchCallback: (SwitchView,Boolean) -> Unit + ): TipsSwitchWindow { + val view = LayoutInflater.from(context).inflate(R.layout.popwindow_switch_tips, null) + popupWindow = PopupWindow(context).apply { + isFocusable = true + contentView = view + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable()) + } + view.findViewById(R.id.tvContent).setText(tips) + view.findViewById(R.id.switchView).run { + this.isChecked = isChecked + setPressChanged { switchCallback(this,it) } + } + return this + } + + fun showAsDropDown(view: View?) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view) + } + } + + fun showAsDropDown(view: View?, xoff: Int, yoff: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view, xoff, yoff) + } + } + + fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAtLocation(parent, gravity, x, y) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt new file mode 100644 index 0000000..972f396 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.configs + +import android.app.Activity +import android.content.res.Configuration +import androidx.multidex.MultiDex +import androidx.multidex.MultiDexApplication +import com.remax.visualnovel.app.base.app.ApplicationProxy +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.app.initializer.AppInitializersProvider +import com.remax.visualnovel.utils.StatusBarUtils +import com.tencent.mmkv.MMKV +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber +import java.lang.ref.WeakReference +import javax.inject.Inject + + +@HiltAndroidApp +class NovelApplication : MultiDexApplication() { + + companion object { + private var currentActivity: WeakReference? = null + + fun setCurrentActivity(activity: Activity?) { + currentActivity = if (activity == null) null else WeakReference(activity) + } + + fun getCurrentActivity(): Activity? { + return currentActivity?.get() + } + } + + private val proxies = listOf(CommonApplicationProxy) + + @Inject + lateinit var appInitializersProvider: AppInitializersProvider + + override fun onCreate() { + super.onCreate() + MultiDex.install(this) + proxies.forEach { it.onCreate(this) } + appInitializersProvider.startInit() + } + + override fun onTerminate() { + MMKV.onExit() + super.onTerminate() + proxies.forEach { + it.onTerminate() + } + } + + // 系统资源配置发生更改回调,例如主题模式,需要重新刷新多语言 + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + StatusBarUtils.resetNavBarHeight() + Timber.d("onConfigurationChanged ${newConfig.locales[0]}") + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt new file mode 100644 index 0000000..ffed149 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.constant + +/** + * Created by HJW on 2021/12/7 + */ +class AppConstant { + + companion object { + const val ANDROID = "android" + const val APP_CLIENT = "ANDROID" + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt new file mode 100644 index 0000000..d885c66 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.constant + +import com.remax.visualnovel.BuildConfig + + +class AppStatus { + + companion object { + + /** + * 是否是生产环境 + */ + val isProduct + get() = BuildConfig.FLAVOR == "product" + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt new file mode 100644 index 0000000..0c334aa --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.constant + +import androidx.annotation.StringRes +import com.remax.visualnovel.R + +/** + * 性别枚举 + */ +enum class Gender(val value: Int, @StringRes val txtRes: Int) { + MALE(0, R.string.male), + FEMALE(1, R.string.female), + NONCONFORMING(2, R.string.nonconforming), + OTHER(2, R.string.other), +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt new file mode 100644 index 0000000..5fb6e86 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel.constant + +/** + * Created by HJW on 2023/8/25 + */ +class LockTypeConstant { + companion object { + const val PUBLIC = 1 + const val PRIVATE = 2 + + /** + * 图片公开 + */ + fun isOpen(pubType: Int?) = pubType != PRIVATE + + /** + * 图片是否解锁 + */ + fun isUnLock(lockType: String?) = lockType != LOCK + + const val LOCK = "LOCK" + const val UNLOCK = "UNLOCK" + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt new file mode 100644 index 0000000..511af44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.constant + +/** + * Created by HJW on 2025/7/21 + */ +enum class StatusCode(val code:String) { + /** + * 登录过期 + */ + TOKEN_EXPIRED("10050001"), + + NO_ALBUM_PERMISSION("10010011"), + + UNUSED_PURCHASE_TOKEN("1019"), //无效的支付凭据 + + AI_USER_NOT_EXIST("10010012"), + + //余额不足 + INSUFFICIENT_BALANCE("INSUFFICIENT_BALANCE"), + + /** + * 前段自定义code + */ + UPLOAD_FILE_VIOLATION("112233") + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt new file mode 100644 index 0000000..79003ea --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel.entity.imbean.raw + +import com.remax.visualnovel.entity.model.base.BasePhoto + +/** + * Created by HJW on 2025/8/21 + */ + +data class CustomAlbumData( + var url: String, + var width: Int, + var height: Int, + var unlockPrice: Long?, + val albumId: Long? +) : BasePhoto() { + + val type = CustomRawData.IMAGE + + var messageServerId: String? = null + + override fun paramId(): Long { + return albumId ?: url.hashCode().toLong() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt new file mode 100644 index 0000000..f2f809c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.entity.imbean.raw + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil + +/** + * Created by HJW on 2025/8/21 + */ + +data class CustomCallData( + val type: String, + val callType: String, + // 通话总时长 + val duration: Long, +) { + companion object { + const val CALL_CANCEL = "CALL_CANCEL" + const val CALL_END = "CALL_END" + } + + val callTxt: String? + get() = when (callType) { + CALL_CANCEL -> { + CommonApplicationProxy.application.getString(R.string.call_canceled) + } + + CALL_END -> { + "${CommonApplicationProxy.application.getString(R.string.call_duration)} ${ + PDateUtil.formatTime( + CommonApplicationProxy.application, + duration + ) + }" + } + + else -> { + null + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt new file mode 100644 index 0000000..f8f688f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt @@ -0,0 +1,12 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/23 + */ +data class CustomGiftData( + val giftId: Int, + val giftName: String, + val giftIcon: String, + val giftNum: Int, + val title: String, +) \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt new file mode 100644 index 0000000..cc1baf1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt @@ -0,0 +1,17 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/27 + */ +data class CustomLevelChangeData( + val type: String, + val title: String, + val heartbeatLevel: String, + val heartbeatLevelName: String, + val heartbeatLevelNum: Int, + val heartbeatVal: Double, +) { + val isLevelUp: Boolean + get() = type == CustomRawData.HEARTBEAT_LEVEL_UP +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt new file mode 100644 index 0000000..c29ac48 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt @@ -0,0 +1,36 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/21 + */ + +data class CustomRawData( + val type: String, + val url: String, + val width: Int, + val height: Int +) { + companion object { + // 发送图片 + const val IMAGE = "IMAGE" + + // IM发送礼物 + const val GIFT = "IM_SEND_GIFT" + + //心动等级升级 + const val HEARTBEAT_LEVEL_UP = "HEARTBEAT_LEVEL_UP" + + //心动等级降级 + const val HEARTBEAT_LEVEL_DOWN = "HEARTBEAT_LEVEL_DOWN" + + // IM通话结束 + const val CALL = "CALL" + + // IM通话中分数变化 + const val VOICE_CHAT_EMOTION_SCORE = "VOICE_CHAT_EMOTION_SCORE" + + //余额不足 关闭语音电话 + const val INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE" + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt new file mode 100644 index 0000000..b49394d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/27 + */ +data class CustomScoreData( + val score: Double +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt new file mode 100644 index 0000000..a2a76ea --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt @@ -0,0 +1,19 @@ +package com.remax.visualnovel.entity.model + +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Photo + + +data class MyImgData( + val viewerId: Long, + val url: String?, + val subsampling: Boolean = false +) : Photo { + override fun id(): Long = viewerId + override fun itemType(): Int { + return when { + subsampling -> ItemType.SUBSAMPLING + else -> ItemType.PHOTO + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt new file mode 100644 index 0000000..93bf7cf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt @@ -0,0 +1,21 @@ +package com.remax.visualnovel.entity.model.base + +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Photo + +/** + * Created by HJW on 2025/7/21 + */ +abstract class BasePhoto : Photo { + + abstract fun paramId(): Long + + override fun id(): Long { + return paramId() + } + + override fun itemType(): Int { + return ItemType.PHOTO + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt new file mode 100644 index 0000000..895e283 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.entity.request + +import com.remax.visualnovel.constant.AppConstant +import com.remax.visualnovel.utils.AppUtils + +/** + * Created by HJW on 2023/10/19 + */ +data class PlatformAccountVerifyDTO( + /** + * 三方账号验证用 + */ + val thirdToken: String? = null, + val thirdType: String? = null, + val appClient: String = AppConstant.APP_CLIENT, + val deviceCode: String = AppUtils.getAndroidID(), + + /** + * common + */ + val authCode: String? = null, +) + +data class CompleteUserInfoInput( + var nickname: String? = null, + var sex: Int? = null, + var birthDay: Long? = null, + var headImage: String? = null, + var exUserId: String? = null, +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt new file mode 100644 index 0000000..0161a86 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt @@ -0,0 +1,32 @@ +package com.remax.visualnovel.entity.response + +import android.os.Parcelable +import com.remax.visualnovel.entity.model.base.BasePhoto +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2025/7/21 + */ + +@Parcelize +data class AppearanceImage( + var imageUrl: String? = null, + var imageWidth: Int? = null, + var imageHeight: Int? = null, + var status: String = PENDING, + var select: Boolean = false, + var isPlaying: Boolean = false, + var unlockPrice: Long = 0, + var tempId: Long = 0, +) : BasePhoto(), Parcelable { + + override fun paramId(): Long = tempId + + companion object { + const val NSFW = "NSFW" + const val COMPLETED = "COMPLETED" + const val FAILED = "FAILED" + const val PENDING = "PENDING" + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt new file mode 100644 index 0000000..30842b0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.entity.response + +/** + * Created by HJW on 2025/8/14 + */ +data class Book( + val aiId: String, + val birthday: Long, + val characterName: String, + val headImg: String +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt new file mode 100644 index 0000000..8aae888 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt @@ -0,0 +1,96 @@ +package com.remax.visualnovel.entity.response + +import android.os.Parcelable +import com.remax.visualnovel.extension.calculateAge +import com.remax.visualnovel.extension.getNimAccountId +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2025/7/17 + */ +@Parcelize +data class Character( + var aiId: String? = null, + var userId: String? = null, + var sex: Int? = null, // 0,男;1,女;2,自定义 + var permission: Int? = null, //权限 1: 公开 2:私密 + var nickname: String? = null, + var idCard: String? = null, + var headImg: String? = null, + var birthday: Long? = null, + var showBirthday: Long? = null, + var roleCode: String? = null, + var roleName: String? = null, + var characterCode: String? = null, + var characterName: String? = null, + var tagCode: String? = null, + var tagName: String? = null, + var introduction: String? = null, // 人物简介 >= 10 字符 <= 300 字符 + var imageUrl: String? = null, + var homeImageUrl: String? = null, // 主页头图 + var imageWidth: Int? = null, + var imageHeight: Int? = null, + var aiUserExt: CharacterExt? = null, + var liked: Boolean? = null, //是否点过赞 + + var likedNum: Long? = null, + var chatNum: Long? = null, + var conversationNum: Long? = null, + var coinNum: Long? = null, + + //在IM中用 + val dialoguePrologue: String? = null, + val dialoguePitch: String? = null, + val dialogueSpeechRate: String? = null, + val voiceType: String? = null, + var backgroundImg: String? = null, + var isDefaultBackground: Boolean? = null, + var isMember: Boolean? = null, + var isHaveChatted: Boolean? = null, //是否聊过天 + var isDelChatted: Boolean? = null, //是否删除过会话 + var isAutoPlayVoice: Int? = null, //自动播放语音开关 1:开 0:关 + //var aiUserHeartbeatRelation: HeartbeatRelation? = null, + //var chatBubble: ChatBubble? = null, + + //排行榜使用 + var rankNo: Int? = null, + var heartbeatValTotal: Double? = null, + var giftCoinNum: Int? = null, + + //首页滑动卡片使用 + var isLimit: Boolean? = null, + var likedCount: Long? = null, + var heartbeatVal: Double? = null, + var character: String? = null, + var role: String? = null, + var tag: String? = null, + var isSecret: Boolean? = null, + //var albumList: List? = null, + + ) : Parcelable { + companion object { + const val UM_FREE = 1 + const val UM_PAID = 2 + } + + val age: Int + get() = (birthday ?: 0L).calculateAge() + + val nimAccountId: String + get() = aiId.getNimAccountId(true) +} + +@Parcelize +data class CharacterExt( + var profile: String? = null, //人物设定 >= 10 字符 <= 4000 字符 + var userProfile: String? = null, //人物设定 >= 10 字符 <= 4000 字符 + var dialogueStyle: String? = null, // 对话风格 >= 10 字符 <= 300 字符 + var userDialogueStyle: String? = null, // 对话风格 >= 10 字符 <= 300 字符 + var dialoguePrologue: String? = null, // 开场白 >= 10 字符 <= 150 字符 + var dialogueTimbreCode: String? = null, // 对话音色Code + var dialoguePitch: String? = null, // 对话-音高 + var dialogueSpeechRate: String? = null, // 对话-语速 + var imageStyleCode: String? = null, //形象风格code + var imageDesc: String? = null, //形象描述 >= 10 字符 <= 500 字符 + var imageReferenceUrl: String? = null, // 形象参考 +) : Parcelable \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt new file mode 100644 index 0000000..add362c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.entity.response + +import com.remax.visualnovel.entity.model.base.BasePhoto + +/** + * Created by HJW on 2025/8/18 + */ +data class ChatBackground( + val backgroundId: Int?, + val imgUrl: String, + var isDefault: Boolean, + var select: Boolean = false, + var isSelected: Boolean? = null, +) : BasePhoto() { + override fun paramId(): Long { + return imgUrl.hashCode().toLong() + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt new file mode 100644 index 0000000..9642172 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt @@ -0,0 +1,22 @@ +package com.remax.visualnovel.entity.response + +/** + * Created by HJW on 2023/10/19 + */ +data class PlatformAccountVerify( + val authType: String, + val optType: String, + val authCode: String, + val token: String?, +) { + val isLogin + get() = !token.isNullOrBlank() + + companion object { + const val OPT_LOGIN = "L" + const val OPT_REGISTER = "R" + + const val AUTH_VC = "VC" + const val AUTH_PD = "PD" + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt new file mode 100644 index 0000000..4e86a81 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.entity.response + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2025/7/11 + */ + +@Parcelize +data class User( + val userId: String, + val idCard: String, + var birthday: Long?, + var nickname: String?, + var headImage: String?, + var sex: Int?, + val cpUserInfo: Boolean?, + val isMember: Boolean?, + val thirdEmail: String?, + val thirdNickname: String?, + val thirdType: String?, + // 可创建AI数量 + var canCreateAiCount: Int, + var createdAiCount: Int, +) : Parcelable + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt new file mode 100644 index 0000000..a56c5d6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt @@ -0,0 +1,19 @@ +package com.remax.visualnovel.entity.response.base + +import kotlinx.parcelize.IgnoredOnParcel + +/** + * Created by HJW on 2025/7/21 + */ +open class BaseVoice { + + open fun id(): String = "" + open fun url(): String = "" + open fun filePathName(): String = "" + + @IgnoredOnParcel + var isPlaying: Boolean = false + + @IgnoredOnParcel + var isLoading: Boolean = false +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt new file mode 100644 index 0000000..28b728e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt @@ -0,0 +1,92 @@ +package com.remax.visualnovel.entity.response.base + + +import com.google.gson.annotations.SerializedName +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.extension.toast + +/** + * Created by HJW on 2022/10/27 + */ +open class Response( + @SerializedName(value = "content") + val data: T? = null, + open val errorCode: String = "", + open val errorMsg: String = "", + val status: String = successCode +) { + + companion object { + const val successCode = "OK" + + /** + * zip打包的错误error封装 + * new + */ + inline fun createZipFailResponse(vararg data: Response<*>): ApiFailedResponse { + val failedResponse = ApiFailedResponse() + for (t in data) { + if (!t.isApiSuccess) { + failedResponse.errorCode = t.errorCode + failedResponse.errorMsg = t.errorMsg + break + } + } + return failedResponse + } + } + + val isOk: Boolean + get() = status == successCode + + val isApiSuccess: Boolean + get() = + this is ApiSuccessResponse || this is ApiEmptyResponse + + /** + * 将返回结果分为成功和失败2个高阶函数 + * + * 使用inline修饰,使2个参数可以调用外部函数return + */ + inline fun transformResult(apiSuccessCallback: ((T?) -> Unit) = {}, apiFailedCallback: ((Response) -> Unit) = {}): Response { + if (isApiSuccess) { + apiSuccessCallback.invoke(data) + } else { + apiFailedCallback.invoke(this) + } + return this + } +} + +inline fun Response.parseData(listenerBuilder: (ResultBuilder.() -> Unit), showToast: Boolean = false) { + val listener = ResultBuilder().also(listenerBuilder) + when (this) { + is ApiSuccessResponse -> listener.onSuccess(this.response) + is ApiEmptyResponse -> listener.onSuccess(null) + is ApiFailedResponse -> { + listener.onFailed(this.errorCode, this.errorMsg) + listener.onFailedWithData(this.data) + if (showToast) { + CommonApplicationProxy.application.toast(errorMsg) + } + } + } + listener.onComplete() +} + +class ResultBuilder { + var onSuccess: (data: T?) -> Unit = {} + var onFailed: (errorCode: String, errorMsg: String) -> Unit = { _, _ -> + + } + var onFailedWithData: (errorData: T?) -> Unit = {} + var onComplete: () -> Unit = {} +} + +data class ApiSuccessResponse(val response: T? = null) : Response(data = response) + +class ApiEmptyResponse : Response() + +data class ApiFailedResponse(override var errorCode: String = "", override var errorMsg: String = "", val errorData: T? = null) : + Response(data = errorData, errorCode = errorCode, errorMsg = errorMsg) + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt new file mode 100644 index 0000000..1b72773 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.event.model + +/** + * Created by HJW on 2023/10/18 + */ +data class OnLoginEvent(val status: Int) { + + fun isLogin(): Boolean = status == LOGIN + + companion object { + const val LOGIN = 1 //登录成功 + const val LOGOUT = 2 //登出成功 + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt new file mode 100644 index 0000000..0a452c6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt @@ -0,0 +1,9 @@ +package com.remax.visualnovel.event.model + +/** + * Created by HJW on 2023/10/25 + */ +data class OnPlayVoiceEvent( + val isStart: Boolean, + val id: String = "" +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt new file mode 100644 index 0000000..669e86f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.event.model.tab + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2023/4/18 + */ +@Parcelize +enum class MainTab(val index: Int, val checkLogin: Boolean = true) : Parcelable { + + /** + * 主页的子fragment + */ + + TAB_BOOKS(0, false), + TAB_MANGAS(1, false), + TAB_ACTORS(2, false), + TAB_HISTORY(3, false), +} + +@Parcelize +enum class ContactTab(val index: Int) : Parcelable { + MESSAGE(0), FRIEND(1) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt new file mode 100644 index 0000000..759f02e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt @@ -0,0 +1,9 @@ +package com.remax.visualnovel.event.model.tab + +/** + * Created by HJW on 2023/10/23 + * + * jumpItem: 主页 tab切换 + */ +data class OnTabChangedEvent(val jumpItem: MainTab, val contactTab: ContactTab? = null) + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt new file mode 100644 index 0000000..e7f3c95 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt @@ -0,0 +1,31 @@ +package com.remax.visualnovel.event.modular + +import com.remax.visualnovel.event.model.OnPlayVoiceEvent +import com.remax.visualnovel.event.model.tab.OnTabChangedEvent +import com.pengxr.modular.eventbus.facade.annotation.EventGroup + +/** + * Created by HJW on 2023/5/22 + * UI操作的事件 + */ +@EventGroup(moduleName = "UI", autoClear = true) +interface UIEvents { + + /** + * 首页底部tab 双击时回到顶部操作 + */ + fun mainScrollToTop() + + fun onNetworkConnect() + + /** + * 主页tab切换 + */ + fun onHomeTabChanged(): OnTabChangedEvent + + /** + * 播放语音开始/结束 + * @return Integer + */ + fun onSkillVoiceEvent(): OnPlayVoiceEvent +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt new file mode 100644 index 0000000..927fcd6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.event.modular + +import com.remax.visualnovel.event.model.OnLoginEvent +import com.pengxr.modular.eventbus.facade.annotation.EventGroup + +/** + * Created by HJW on 2023/5/18 + * 当前用户相关的事件 + */ +@EventGroup(moduleName = "user", autoClear = true) +interface UserEvents { + + /** + * 登录状态变更 + */ + fun onLoginStatusChanged(): OnLoginEvent + + /** + * 个人信息更新 + */ + fun onPersonalInfoChanged() + + /** + * vip订阅成功 + */ + fun onVipSubscribed() + + /** + * 用户信息发生改变 + */ + fun onUserInfoChanged() + + /** + * 用户未读消息数改变 + */ + fun onUserUnReadChanged() +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt new file mode 100644 index 0000000..eb759d8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt @@ -0,0 +1,106 @@ +package com.remax.visualnovel.extension + +import android.view.View +import androidx.core.widget.NestedScrollView +import com.remax.visualnovel.app.delegate.navBgAlpha +import com.remax.visualnovel.app.delegate.titleTextAlpha +import com.remax.visualnovel.utils.KeyboardUtils +import com.google.android.material.appbar.AppBarLayout +import com.remax.visualnovel.configs.NovelApplication + +/** + * Created by HJW on 2023/2/13 + */ + +/** + * 当布局中有AppBarLayout时,滑动渐隐公共toolbar的标题 + * @param titleView 滑动到title。渐变完成 + * @param titleGroup 当页面撑满整个状态栏时,给toolbarLayout最小高度,当toolbarLayout中的元素比较多时,渐变只到titleTextView的底部,才传这个进来 + * @param includeBgAlpha 是否渐隐toolbar的背景色 + * @param isFromToolbar toolbar是否在页面上方,默认toolbar和contentLayout重叠在一起,即是false + * + */ +fun AppBarLayout.addScrollerAlpha( + titleView: View, + titleGroup: View? = null, + includeBgAlpha: Boolean = false, + isFromToolbar: Boolean = false, + textAlpha: Boolean = true, + alphaChanged: ((alpha: Float, verticalOffset: Int) -> Unit)? = null +) { + context.findBaseActivity()?.apply { + if (includeBgAlpha) { + // 当主页面填满整个屏幕时,给一个最小高度,和标题栏的高度一样,滑动的时候显示即正常 + (titleGroup ?: titleView).minimumHeight = getNavHeight() + } + addOnOffsetChangedListener { _, verticalOffset -> + updateToolbar { + val alpha = if (isFromToolbar) + calculateScrollerAlphaFromToolbar(verticalOffset, titleView.bottom) + else + calculateScrollerAlpha(verticalOffset, titleView.bottom) + + alphaChanged?.invoke(alpha, verticalOffset) + if (textAlpha) { + titleTextAlpha = alpha + } + if (includeBgAlpha) { + navBgAlpha = alpha + } + } + } + } +} + +/** + * 同上,这是NestedScrollView的渐隐扩展方法 + * */ +fun NestedScrollView.addScrollerAlpha( + titleView: View, + includeBgAlpha: Boolean = false, + isFromToolbar: Boolean = false, + textAlpha: Boolean = true, + scrollChange: ((alpha: Float, scrollY: Int) -> Unit)? = null +) { + context.findBaseActivity()?.apply { + if (textAlpha) { + updateToolbar { + titleTextAlpha = 0f + } + } + setOnScrollChangeListener { _, _, scrollY, _, _ -> + if (KeyboardUtils.isSoftInputVisible(this)) { + KeyboardUtils.hideSoftInput(this) + } + val alpha = if (isFromToolbar) calculateScrollerAlphaFromToolbar( + scrollY, + titleView.bottom + ) else calculateScrollerAlpha( + scrollY, + titleView.bottom + ) + scrollChange?.invoke(alpha, scrollY) + updateToolbar { + if (textAlpha) { + titleTextAlpha = alpha + } + if (includeBgAlpha) { + navBgAlpha = alpha + } + } + } + } +} + +/** + * 滑动时关闭键盘 + */ +fun NestedScrollView.scrollAndHideKeyboard() { + setOnScrollChangeListener { _, _, _, _, _ -> + NovelApplication.getCurrentActivity()?.let { act -> + if (KeyboardUtils.isSoftInputVisible(act)) { + KeyboardUtils.hideSoftInput(act) + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt new file mode 100644 index 0000000..7795ee2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt @@ -0,0 +1,52 @@ +package com.remax.visualnovel.extension + + +import android.app.Activity +import android.content.Intent + + + +///** +// * 退出编辑二次确认弹窗 +// */ +//fun Activity.showExitHintDialog(isFinish: Boolean = true, continueCallback: (() -> Unit)? = null) { +// showDoubleBtnDialog( +// getString(R.string.tips), +// getString(R.string.im_auto_input_back), +// rightBtnText = getString(R.string.continue_hint), +// rightBtnClick = { +// if (KeyboardUtils.isSoftInputVisible(this)) { +// KeyboardUtils.hideSoftInput(this) +// } +// continueCallback?.invoke() +// if (isFinish) { +// finish() +// } +// }) +//} + + +///** +// * 展示余额不足弹窗 +// */ +//fun AppCompatActivity.activityShowChargeDialog() { +// showDoubleBtnDialog( +// getString(R.string.tips), +// getString(R.string.balance_insufficient_hint), +// rightBtnText = getString(R.string.recharge), +// rightBtnClick = { +// ChargeActivity.open() +// }) +//} + + +/** + * 设置activity返回参数 + */ +inline fun Activity.setResultAndFinish(isFinish: Boolean = true, block: Intent.() -> Unit = {}) { + setResult(Activity.RESULT_OK, Intent().apply { + this.block() + }) + if (isFinish) finish() +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt new file mode 100644 index 0000000..9143837 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.extension + +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import eightbitlab.com.blurview.BlurView + +/** + * Created by HJW on 2025/8/8 + */ +fun BlurView.setup(root: ViewGroup, @ColorRes overlayColorRes: Int) { + setupWith(root) + setOverlayColor(ContextCompat.getColor(context, overlayColorRes)) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt new file mode 100644 index 0000000..2d7112a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.extension + +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + + +/** + * 切换点赞爱心状态 + */ +fun UITokenTextView.changeLikedStatus(isLike: Boolean) { + isVisible = true + changeTextColor { + textUIColorToken = + context.getString(if (isLike) R.string.color_important_normal else R.string.color_txt_primary_normal) + } + setText(if (isLike) R.string.icon_like_fill else R.string.icon_like) +} + +///** +// * 切换follow following 按钮状态 +// */ +//fun ButtonView.changeFollowStatus(followedStatus: String?) { +// when (followedStatus) { +// FollowedStatus.FOLLOWED -> { +// setText(R.string.following) +// setButtonStyle(ButtonView.DefaultButton_Tertiary) +// } +// +// FollowedStatus.CANCELED -> { +// setText(R.string.follow) +// setButtonStyle(ButtonView.DefaultButton_Primary) +// } +// +// else -> { +// setText(R.string.follow) +// setButtonStyle(ButtonView.DefaultButton_Primary) +// } +// } +//} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt new file mode 100644 index 0000000..39df9e9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt @@ -0,0 +1,244 @@ +package com.remax.visualnovel.extension + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Typeface +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.scale +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.BaseBindingActivity +import com.remax.visualnovel.utils.StatusBarUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.manager.CustomToastManager +import dagger.hilt.android.internal.managers.FragmentComponentManager +import timber.log.Timber +import java.io.IOException + +/** + * context扩展方法 + */ + +/** + * 获取标题栏高度 + * navheight + 是否显示状态栏?:状态栏高度 or 0 + */ +fun Context.getNavHeight(isFullScreen: Boolean = true) = + resources.getDimensionPixelSize(R.dimen.nav_height) + if (isFullScreen) StatusBarUtils.statusBarHeight else 0 + + +/** + * 在fragment中使用,hilt注解库将fragment的context进行了包装 + * 外面需要判断强转 + */ +fun Context.findActivityContext(): Context { + return FragmentComponentManager.findActivity(this) +} + +fun Context.findBaseActivity(): BaseBindingActivity<*>? = findActivityContext() as? BaseBindingActivity<*> + +/** + * 快速设置状态栏颜色 + * color.background.specialmap + * color.background.district + * color.background.default + */ +fun Context.getBgColor(bgColorType: BgColorType = BgColorType.SPECIAL_MAP): Int { + return when (bgColorType) { + BgColorType.DEFAULT -> { + handleUIToken(R.string.color_background_default)?.color ?: 0 + } + + BgColorType.DISTRICT -> { + handleUIToken(R.string.color_background_district)?.color ?: 0 + } + + BgColorType.SPECIAL_MAP -> { + handleUIToken(R.string.color_background_specialmap)?.color ?: 0 + } + + BgColorType.TRANSPARENT -> { + handleUIToken(R.string.color_transparent)?.color ?: 0 + } + } +} + +fun Activity.setStatusBarColor(bgColorType: BgColorType = BgColorType.SPECIAL_MAP) { + val color = getBgColor(bgColorType) + StatusBarUtils.setColor(this, color) +} + +/** + * 返回iconFont的typeface + */ +private var iconFontTypeface: Typeface? = null +fun Context.getIconFontType(): Typeface { + if (iconFontTypeface == null) { + iconFontTypeface = Typeface.createFromAsset(assets, "iconfont/iconfont.ttf") + } + return iconFontTypeface!! +} + +private var D_Din_700: Typeface? = null +private var Bangers_400: Typeface? = null +private var Poppins_400: Typeface? = null +private var Poppins_500: Typeface? = null +private var Poppins_600: Typeface? = null +private var Poppins_700: Typeface? = null + +const val D_Din_700_typeface = "family/D-Din-700.ttf" +const val Bangers_400_typeface = "family/Bangers-400.ttf" +const val Poppins_400_typeface = "family/Poppins-400.ttf" +const val Poppins_500_typeface = "family/Poppins-500.ttf" +const val Poppins_600_typeface = "family/Poppins-600.ttf" +const val Poppins_700_typeface = "family/Poppins-700.ttf" + +/** + * 缓存字体包 + * @receiver Context + * @param typeface String? + * @return Typeface + */ +fun Context.getTextFontTypeface(typeface: String?): Typeface { + return when (typeface) { + Poppins_400_typeface -> { + if (Poppins_400 == null) { + Poppins_400 = Typeface.createFromAsset(assets, typeface) + } + Poppins_400 ?: Typeface.createFromAsset(assets, typeface) + } + + Poppins_500_typeface -> { + if (Poppins_500 == null) { + Poppins_500 = Typeface.createFromAsset(assets, typeface) + } + Poppins_500 ?: Typeface.createFromAsset(assets, typeface) + } + + Poppins_600_typeface -> { + if (Poppins_600 == null) { + Poppins_600 = Typeface.createFromAsset(assets, typeface) + } + Poppins_600 ?: Typeface.createFromAsset(assets, typeface) + } + + Poppins_700_typeface -> { + if (Poppins_700 == null) { + Poppins_700 = Typeface.createFromAsset(assets, typeface) + } + Poppins_700 ?: Typeface.createFromAsset(assets, typeface) + } + + D_Din_700_typeface -> { + if (D_Din_700 == null) { + D_Din_700 = Typeface.createFromAsset(assets, typeface) + } + D_Din_700 ?: Typeface.createFromAsset(assets, typeface) + } + + Bangers_400_typeface -> { + if (Bangers_400 == null) { + Bangers_400 = Typeface.createFromAsset(assets, typeface) + } + Bangers_400 ?: Typeface.createFromAsset(assets, typeface) + } + + else -> { + Typeface.createFromAsset(assets, typeface) + } + } +} + +fun Context.toast(msg: String?, isLong: Boolean = false) { + Handler(Looper.getMainLooper()).post { + CustomToastManager.showToast(this, msg, isLong) + } +} + +fun Context.toast(@StringRes msg: Int, isLong: Boolean = false) { + toast(getString(msg), isLong) +} + +/** + * 复制到剪切板 + */ +fun Context.copyLink(link: String?) { + if (link == null) return + val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val data = ClipData.newPlainText("Label", link) + cm.setPrimaryClip(data) + toast(getString(R.string.copy_successful)) +} + +@Throws(IOException::class) +fun Context.readJsonAsset(fileName: String): String { + val inputStream = assets.open(fileName) + val size = inputStream.available() + val buffer = ByteArray(size) + inputStream.read(buffer) + inputStream.close() + return String(buffer, Charsets.UTF_8) +} + + +/** + * 打开外部浏览器 + * alternateUrl:备用地址 外部应用不存在的情况下调至备用页面 + */ +fun Context.jumpBrowser(url: String?, alternateUrl: String? = null) { + runCatching { + var realUrl = url ?: "" + if (realUrl.isNotBlank()) { + if (!realUrl.startsWith("http://") && !realUrl.startsWith("https://")) { + realUrl = "http://$realUrl" + } + val uri = Uri.parse(realUrl) + Intent(Intent.ACTION_VIEW, uri).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(this) + } + } + }.onFailure { + alternateUrl?.let(::jumpBrowser) + } +} + +/** + * 调用系统分享 + */ +fun Context.jumpShare(aiId: String?) { + try { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + val url = "${BuildConfig.HOST}@$aiId" + intent.putExtra(Intent.EXTRA_TEXT, url) + startActivity(Intent.createChooser(intent, getString(R.string.share))) + } catch (e: Exception) { + Timber.e(e) + } +} + + +/** + * 创建一个大小固定的bitmap + */ +fun Context.createScaledBitmap(@DrawableRes drawableRes: Int, size: Int? = null): Bitmap = + if (size != null) { + (ContextCompat.getDrawable(this, drawableRes) as BitmapDrawable).bitmap.scale(size.dp, size.dp, false) + } else { + Bitmap.createBitmap((ContextCompat.getDrawable(this, drawableRes) as BitmapDrawable).bitmap) + } + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt new file mode 100644 index 0000000..a857d48 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.extension + +import kotlinx.coroutines.CancellableContinuation +import kotlin.coroutines.resume + +/** + * Created by HJW on 2023/7/14 + */ + +fun CancellableContinuation.resumeWithActive(value: T) { + if (isActive) { + resume(value) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt new file mode 100644 index 0000000..3b4dfa9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt @@ -0,0 +1,291 @@ +package com.remax.visualnovel.extension + +import android.annotation.SuppressLint +import android.app.Activity +import android.text.method.ScrollingMovementMethod +import android.view.Gravity +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.DialogDoubleBtnBinding +import com.remax.visualnovel.databinding.DialogSingleBtnLayout2Binding +import com.remax.visualnovel.databinding.DialogSingleBtnLayoutBinding +import com.remax.visualnovel.databinding.DialogSingleBtnWithIconBinding +import com.remax.visualnovel.extension.glide.loadNoCenterCrop +import com.remax.visualnovel.utils.KeyboardUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.dialoglib.LBindingDialog +import com.remax.visualnovel.widget.dialoglib.ScreenUtils +import com.remax.visualnovel.widget.ui.buttons.ButtonView +import com.remax.visualnovel.widget.uitoken.changeTextStyle +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import kotlin.math.min + +/** + * Created by HJW on 2025/7/18 + */ + +/** + * 双按钮的全局统一的dialog样式 + */ +fun Activity.showDoubleBtnDialog( + title: String = getString(R.string.tips), + text: String, + topBtnText: String? = null, + topBtnClick: (() -> Unit)? = null, + bottomBtnText: String? = null, + bottomBtnClick: (() -> Unit)? = null, + isShow: Boolean = true, + isDel: Boolean = false, + contentGravity: Int = Gravity.CENTER, + needMaxHeight: Boolean = true, + @DrawableRes titleImage: Int? = null +): LBindingDialog { + val dialog = LBindingDialog(this, DialogDoubleBtnBinding::inflate) + .with() + .setCenter() + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + if (needMaxHeight) { + textTv.maxHeight = (ScreenUtils.getHeightRealPixels() * 0.7).toInt() + textTv.movementMethod = ScrollingMovementMethod.getInstance() + } + + setOnClick(leftBtn, rightBtn) { + when (this) { + leftBtn -> bottomBtnClick?.invoke() + rightBtn -> topBtnClick?.invoke() + } + dialog.dismiss() + } + + titleTv.text = title + titleIv.isVisible = titleImage != null + if (titleIv.isVisible) { + titleTv.isVisible = false + titleIv.setImageResource(titleImage!!) + textTv.setMargin(topMargin = 10.dp) + } + + textTv.gravity = contentGravity + textTv.text = "$text\n" + if (bottomBtnText != null) { + leftBtn.text = bottomBtnText + } + if (isDel){ + rightBtn.setButtonStyle(ButtonView.DefaultButton_Destructive) + } + if (topBtnText != null) { + rightBtn.text = topBtnText + } + } + + if (isShow) { + dialog.show() + } + + return dialog +} + +/** + * 单按钮的全局统一的dialog样式 + */ +fun Activity.showSingleBtnDialog( + title: String = getString(R.string.tips), + text: String, + btnText: String? = null, + contentGravity: Int = Gravity.CENTER, + isShow: Boolean = true, + autoDismiss: Boolean = true, + needMaxHeight: Boolean = true, + btnClick: (() -> Unit)? = null +): LBindingDialog { + val dialog = showSingleCancelBtnDialog(title, text, btnText, btnClick, isShow, autoDismiss, contentGravity, needMaxHeight) + dialog.binding.cancelBtn.isVisible = false + return dialog +} + + +/** + * 单按钮带图标的全局统一的dialog样式 + */ +fun Activity.showIconAndSingleBtnDialog( + title: String, + text: String, + iconRes: Int? = null, + btnText: String? = null, + btnClick: (() -> Unit)? = null, + isShow: Boolean = true, + closeIsShow: Boolean = false, + iconUrl: String? = null, + contentGravity: Int = Gravity.CENTER, + needMaxHeight: Boolean = true +): LBindingDialog { + val dialog = LBindingDialog(this, DialogSingleBtnWithIconBinding::inflate).with() + .setCenter() + .setCancelBtn(R.id.ivClose) + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + if (needMaxHeight) { + tvText.maxHeight = (ScreenUtils.getHeightRealPixels() * 0.7).toInt() + tvText.movementMethod = ScrollingMovementMethod.getInstance() + } + tvText.gravity = contentGravity + setOnClick(tvBtn) { + dialog.dismiss() + btnClick?.invoke() + } + + ivClose.isVisible = closeIsShow + if (iconRes != null) { + ivIcon.setImageResource(iconRes) + } else { + ivIcon.loadNoCenterCrop(iconUrl ?: "") + } + tvTitle.text = title + tvText.text = text + tvBtn.text = btnText ?: this@showIconAndSingleBtnDialog.getString(R.string.i_understand) + } + if (isShow) { + dialog.show() + } + return dialog +} + +fun Activity.showMoreTxtDialog( + titleText: String = getString(R.string.tips), + texts: List, + btnText: String? = null, + btnClick: (() -> Unit)? = null, + isShow: Boolean = true, + autoDismiss: Boolean = true, + showCloseBotton: Boolean = false, + contentGravity: Int = Gravity.START, + showCancel: Boolean = false +): LBindingDialog { + val dialog = LBindingDialog(this, DialogSingleBtnLayout2Binding::inflate).with() + .setCenter() + .setCancelBtn(R.id.cancelBtn) + .setCancelBtn(R.id.closeBtn) + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + cancelBtn.isVisible = showCancel + closeBtn.isVisible = showCloseBotton + + dialog.setOnShowListener { + val groupHeight = group.measuredHeight + val maxHeight = ScreenUtils.getHeightRealPixels() * 0.7f - 24.dp - 96.dp + scrollView.setSize(height = min(groupHeight, maxHeight.toInt())) + } + + setOnClick(okBtn) { + btnClick?.invoke() + if (autoDismiss) { + dialog.dismiss() + } + } + okBtn.setText(R.string.i_understand) + btnText?.let(okBtn::setText) + + title.text = titleText + + group.removeAllViews() + texts.forEachIndexed { index, s -> + val textView = UITokenTextView(this@showMoreTxtDialog) + group.addView(textView) + with(textView) { + setSize(ViewGroup.LayoutParams.MATCH_PARENT) + text = s + gravity = contentGravity + changeTextStyle { + textUIColorToken = getString(R.string.color_txt_primary_normal) + textUITextToken = getString(R.string.txt_body_m) + } + if (index > 0) { + setMargin(topMargin = 16.dp) + } + } + } + if (isShow) { + dialog.show() + } + } + + return dialog +} + +/** + * 单按钮的全局统一的dialog样式-待顶部关闭按钮 + */ +@SuppressLint("SetTextI18n") +fun Activity.showSingleCancelBtnDialog( + title: String, + text: String, + btnText: String? = null, + btnClick: (() -> Unit)? = null, + isShow: Boolean = true, + autoDismiss: Boolean = true, + contentGravity: Int = Gravity.CENTER, + needMaxHeight: Boolean = true +): LBindingDialog { + val dialog = LBindingDialog(this, DialogSingleBtnLayoutBinding::inflate) + .with() + .setCenter() + .setCancelBtn(R.id.cancelBtn) + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + if (needMaxHeight) { + singleText.maxHeight = (ScreenUtils.getHeightRealPixels() * 0.7).toInt() + singleText.movementMethod = ScrollingMovementMethod.getInstance() + } + + setOnClick(singleBtn) { + btnClick?.invoke() + if (autoDismiss) { + dialog.dismiss() + } + } + + singleTitle.text = title + singleText.text = "$text\n" + singleText.gravity = contentGravity + singleBtn.text = btnText ?: this@showSingleCancelBtnDialog.getString(R.string.i_understand) + } + if (isShow) { + dialog.show() + } + return dialog +} + + +/** + * 退出编辑二次确认弹窗 + */ +fun Activity.showExitHintDialog(isFinish: Boolean = true, continueCallback: (() -> Unit)? = null) { + showDoubleBtnDialog( + getString(R.string.tips), + getString(R.string.exit_dialog_hint), + topBtnText = getString(R.string.exit), + topBtnClick = { + if (KeyboardUtils.isSoftInputVisible(this)) { + KeyboardUtils.hideSoftInput(this) + } + continueCallback?.invoke() + if (isFinish) { + finish() + } + }) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt new file mode 100644 index 0000000..7839d71 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.extension + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy + +/** + * Created by HJW on 2025/8/27 + */ + + +fun Double?.getTemperatureTxt() = "$this${CommonApplicationProxy.application.getString(R.string.temperature)}" \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt new file mode 100644 index 0000000..6f92465 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt @@ -0,0 +1,67 @@ +package com.remax.visualnovel.extension + +import android.view.KeyEvent +import android.widget.EditText +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.internal.ThreadUtil +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch + +/** + * 搜索框防抖 + * tips: flow流需要在协程中创建,使用当前EditText的activity.lifecycleOwner创建,搜索不走的话,需要另行创建协程 + */ +fun EditText.startSearch(resInvoke: (res: String) -> Unit) { + (context.findActivityContext() as? AppCompatActivity)?.run { + lifecycleScope.launch { + textChangeFlow() + .debounce(500) + .collect { + resInvoke.invoke(it) + } + } + } +} + +private fun EditText.textChangeFlow(): Flow { + return callbackFlow { + require(ThreadUtil.isMainThread()) + val listener = doOnTextChanged { text, _, _, _ -> trySend(text?.toString() ?: "") } + awaitClose { removeTextChangedListener(listener) } + } +} + +/** + * 屏蔽回车换行 + */ +fun EditText.filterEnter() { + this.setOnEditorActionListener { _, _, event -> + (event?.keyCode ?: 0) == KeyEvent.KEYCODE_ENTER + } +} + +/** + * 输入框不允许输入换行符 + */ +fun EditText.withoutNewLineChanges(resInvoke: (res: CharSequence) -> Unit) { + this.run { + filterEnter() + doAfterTextChanged { + if (this.isFocused) { + if (it?.contains("\n") == true) { + val content = it.toString().replace("\n", "") + this.setText(content) + this.setSelection(content.length) + } else { + resInvoke.invoke(it?.toString() ?: "") + } + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt new file mode 100644 index 0000000..658a2ae --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import android.os.Environment +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.util.Base64 + +/** + * Created by HJW on 2025/8/21 + */ +/** + * 将文件转换为 Base64 字符串 + * @return Base64 编码的字符串 + * @throws IOException 如果读取文件失败 + */ +fun File.toBase64(): String { + // 检查文件是否存在 + if (!this.exists() || !this.isFile) { + Timber.e("File.toBase64() File does not exist or is not a valid file") + return "" + } + + // 使用 FileInputStream 读取文件内容 + return FileInputStream(this).use { inputStream -> + // 创建缓冲区,优化大文件读取 + val buffer = ByteArray(8192) + val byteArrayOutputStream = java.io.ByteArrayOutputStream() + var bytesRead: Int + + // 流式读取文件 + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + byteArrayOutputStream.write(buffer, 0, bytesRead) + } + + // 转换为 Base64 字符串 + Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()) + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt new file mode 100644 index 0000000..fd764c9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import kotlin.math.abs + +/** + * Created by HJW on 2022/9/27 + */ + +//计算滑动alpha 页面从屏幕顶部开始 +internal fun Context.calculateScrollerAlpha(offset: Int, height: Int) = calculateScrollerAlphaFromToolbar(offset, getFullScrollerTopHeight(height)) + +//计算滑动alpha 页面从导航栏下方开始 +internal fun calculateScrollerAlphaFromToolbar(offset: Int, height: Int) = abs(offset.toFloat() / height.toFloat()) + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt new file mode 100644 index 0000000..7df7fcb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt @@ -0,0 +1,144 @@ +package com.remax.visualnovel.extension + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.remax.visualnovel.app.AbsView +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.entity.response.base.ResultBuilder +import com.remax.visualnovel.entity.response.base.parseData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +inline fun launchFlow( + crossinline requestBlock: suspend () -> Response, + noinline startCallback: (() -> Unit)? = null, + noinline completeCallback: (() -> Unit)? = null, +): Flow> { + return flow { + emit(requestBlock()) + }.onStart { + startCallback?.invoke() + }.onCompletion { + completeCallback?.invoke() + }.flowOn(Dispatchers.Main) +} + +/** + * 简单的请求,不返回任何实体类, 无loading和toast,数据可通过livedata/flow监听 + */ +inline fun AbsView.launchWithRequest(crossinline requestBlock: suspend () -> Unit, showLoading: Boolean = false) { + lifecycleScope.launch { + flow { + emit(requestBlock()) + }.onStart { + if (showLoading) showLoading() + }.onCompletion { + if (showLoading) hideLoading() + }.collect() + } +} + +/** + * 调用上面的,默认loading + */ +inline fun AbsView.launchWithRequestLoading(crossinline requestBlock: suspend () -> Unit) { + launchWithRequest(requestBlock, true) +} + +/** + * 链式调用,返回结果的处理都在一起,viewmodel中不需要创建一个livedata对象 + * 适用于不需要监听数据变化/一次性使用的场景,比如提交表单/登录 + * 屏幕旋转,Activity销毁重建,数据会消失 + * + * 默认无toast,无loading + */ +inline fun AbsView.launchAndCollect( + crossinline requestBlock: suspend () -> Response, + showLoading: Boolean = false, + showToast: Boolean = true, + crossinline listenerBuilder: (ResultBuilder.() -> Unit) = {} +) { + lifecycleScope.launch { + launchFlow(requestBlock, { if (showLoading) showLoading() }) { if (showLoading) hideLoading() }.collect { response -> + response.parseData(listenerBuilder, showToast) + } + } +} + +inline fun AbsView.launchAndLoadingCollect( + crossinline requestBlock: suspend () -> Response, showToast: Boolean = true, crossinline listenerBuilder: (ResultBuilder.() -> Unit) = {} +) { + launchAndCollect(requestBlock, showLoading = true, showToast = showToast, listenerBuilder = listenerBuilder) +} + +/** + * 简单flow流订阅 生命周期安全 + */ +inline fun Flow.flowWithLaunch( + lifecycleOwner: LifecycleOwner, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, crossinline resCallback: ((t: T?) -> Unit) +) { + lifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect { + resCallback.invoke(it) + } + } +} + + +fun Flow.flowWithLifecycle(lifecycle: Lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED): Flow = callbackFlow { + lifecycle.repeatOnLifecycle(minActiveState) { + this@flowWithLifecycle.collect { + send(it) + } + } + close() +} + +/** + * response liveData监听 + */ +inline fun LiveData>.observeIn( + lifecycleOwner: LifecycleOwner, showToast: Boolean = true, crossinline listenerBuilder: ResultBuilder.() -> Unit +) { + this.observe(lifecycleOwner, Observer { + it.parseData(listenerBuilder, showToast) + }) +} + +/** + * 订阅UI上展示Flow数据流 + * + * 状态(State)用 StateFlow,粘性的 ;事件(Event)用 SharedFlow 在其 replayCache 中保留特定数量的最新值 + * MutableSharedFlow :一次性事件,不需要重放的状态变更(例如 Toast) + * MutableStateFlow : 页面需要的状态,比如UI的刷新,多次执行没有任何问题 + * collectLastValue = true时,stateFlow也不会发送未改变的value,就和sharedFlow一样的用法 + */ +inline fun Flow>.collectIn( + lifecycleOwner: LifecycleOwner, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + showToast: Boolean = true, + crossinline listenerBuilder: ResultBuilder.() -> Unit +): Job { + return lifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect { + it.parseData(listenerBuilder, showToast) + } + } +} + + + + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt new file mode 100644 index 0000000..c20ec3b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt @@ -0,0 +1,145 @@ +package com.remax.visualnovel.extension + +import android.os.SystemClock +import android.view.View +import androidx.lifecycle.LifecycleCoroutineScope +import com.remax.visualnovel.R +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.Routers +import com.remax.visualnovel.utils.TimeUtils +import com.dylanc.loadingstateview.viewClickHandler +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * 批量添加点击事件,防抖500毫秒 + * */ +inline fun setOnClick(vararg v: View?, crossinline block: viewClickHandler) { + val interval = 500L + v.forEach { + it?.let { view -> + view.setOnClickListener { + val lastClickedTimestamp = view.getTag(R.id.view_last_click_timestamp)?.toString()?.toLongOrNull() ?: 0L + val currTimestamp = SystemClock.uptimeMillis() + if (currTimestamp - lastClickedTimestamp < interval) return@setOnClickListener + view.setTag(R.id.view_last_click_timestamp, currTimestamp) + view.block() + } + } + } +} + +/** + * 检查是否登录,未登录直接跳转登录页面 + * */ +fun checkLogin(then: (() -> Unit)? = null): Boolean { + return if (LoginManager.isLogin) { + then?.invoke() + true + } else { + Routers.navigation(Routers.LOGIN) + false + } +} + +//防止弹窗多次弹出 +private var addGamerIdHintDialogIsShow = false + +inline fun String?.convertFromJson(): T? { + return try { + Gson().fromJson(this ?: "", T::class.java) + } catch (_: Exception) { + null + } +} + +/** + * map 解析json + * */ +inline fun Map?.convertFromJson(): T? { + return try { + Gson().fromJson(Gson().toJson(this ?: HashMap()) ?: "", T::class.java) + } catch (_: Exception) { + null + } +} + +inline fun convertListFromJson(param: String?): List { + return try { + val itemType = object : TypeToken>() {}.type + Gson().fromJson(param, itemType) ?: emptyList() + } catch (e: Exception) { + emptyList() + } +} + +val buffDecimalFormat: DecimalFormat + get() { + return DecimalFormat("#,###,##0.00") + } + + +fun formatBuff(buff: Long?): String { + val obj = BigDecimal(buff ?: 0L).divide(BigDecimal(100), 2, RoundingMode.DOWN) + return buffDecimalFormat.format(obj.toDouble()) +} + +fun formatBuffWithIntegers(buff: Long?): String { + val buffInt = ((buff ?: 0L) / 100) + return formatNumber(buffInt) +} + +/** + * 价格输入框不能格式化价格 + */ +fun formatPrice(buff: Long?, isIntegers: Boolean = true) = if (isIntegers) + formatBuffWithIntegers(buff) +else + BigDecimal(buff ?: 0L).divide(BigDecimal(100)).setScale(2).toString() + +val numberDecimalFormat = DecimalFormat("#,###,###") +fun formatNumber(number: Long?): String { + val formatNumber = number ?: 0 + if (formatNumber < 1000) return formatNumber.toString() + return numberDecimalFormat.format(formatNumber) +} + +val birthDateFormatParse by lazy { SimpleDateFormat(TimeUtils.YMD_PATTERN, Locale.getDefault()) } + +/** + * 语音录音计时器 + * + * @param total + * @return job + */ +fun LifecycleCoroutineScope.countDownCoroutines(total: Int, onTick: (Int) -> Unit, onFinish: () -> Unit): Job { + return flow { + for (i in total downTo 1) { + emit(i) + delay(1000) + } + } + .flowOn(Dispatchers.Main) + .onCompletion { + onFinish.invoke() + } + .onEach { + onTick.invoke(it) + } + .launchIn(this) +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt new file mode 100644 index 0000000..5929cf4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.extension + +import android.widget.ImageView +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.constant.Gender + +/** + * Created by HJW on 2025/7/16 + */ + +/** + * 显示性别 + */ +fun ImageView.showGender(gender: Int?) { + isVisible = true + when (gender) { + Gender.MALE.value -> setImageResource(R.mipmap.ic_gender_male) + + Gender.FEMALE.value -> setImageResource(R.mipmap.ic_gender_female) + + Gender.NONCONFORMING.value -> setImageResource(R.mipmap.ic_gender_nonconforming) + + Gender.OTHER.value -> setImageResource(R.mipmap.ic_gender_nonconforming) + + else -> { + isVisible = false + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt new file mode 100644 index 0000000..c089465 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.StatusBarUtils + +/** + * Created by HJW on 2022/9/27 + */ +internal fun Context.getFullScrollerTopHeight(height: Int) = + height - StatusBarUtils.statusBarHeight - resources.getDimensionPixelSize(R.dimen.nav_height) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt new file mode 100644 index 0000000..6841585 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt @@ -0,0 +1,88 @@ +package com.remax.visualnovel.extension + +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import com.remax.visualnovel.widget.ui.bannerindicator.RectangleIndicator +import com.youth.banner.Banner + +/** + * Created by HJW on 2025/7/31 + */ + +/** + * 安全反转列表,不使用 reversed() 或 asReversed(),避免 NoSuchMethodError。 + * @param input 任意输入(List、null 或其他类型)。 + * @return 反转后的列表,若输入无效则返回空列表。 + */ +fun Any?.safeReverseList(): List { + // 处理 null 输入 + if (this == null) { + return emptyList() + } + + // 检查是否为 List 类型 + if (this !is List<*>) { + return emptyList() + } + + // 创建新的可变列表用于存储反转结果 + val result = mutableListOf() + + // 手动反转:从最后一个元素开始向前遍历 + for (i in this.size - 1 downTo 0) { + try { + // 安全转换元素类型,避免 ClassCastException + @Suppress("UNCHECKED_CAST") + val element = this[i] as T + result.add(element) + } catch (e: ClassCastException) { + // 类型转换失败,记录日志并跳过该元素 + println("跳过无效元素: ${e.message}") + continue + } + } + + // 返回只读列表,防止外部修改 + return result.toList() +} + + +/** + * 将数据按 @param chunkNum 个一页展示在banner上 + * @receiver List 原始数据 + * @param indicator RectangleIndicator banner指示器 + * @param banner Banner<*, *> banner控件 + * @param transform Function1 请求回来的数据转换成UI数据 + * @param placeholderFactory 新增工厂函数,用于创建占位符 + */ +inline fun List.getBannerChunkData( + chunkNum: Int, + banner: Banner<*, *>, + transform: (T) -> U, + placeholderFactory: (U) -> U, + owner: LifecycleOwner? = null, + indicator: RectangleIndicator? = null +): List> { + val splitData = this.map(transform).chunked(chunkNum).toMutableList() + indicator?.isVisible = splitData.size > 1 + /** + * 从第二页开始,补满8个item,预防高度发生变化 + */ + splitData.forEachIndexed { index, rewardUIData -> + if (index > 0 && rewardUIData.size < chunkNum) { + val mutableData = rewardUIData.toMutableList() + val first = mutableData.firstOrNull() + repeat(chunkNum - mutableData.size) { + first?.let { + mutableData.add(placeholderFactory(it)) + } + } + splitData[index] = mutableData + } + } + indicator?.let { + banner.setIndicator(indicator, false) + } + banner.addBannerLifecycleObserver(owner) + return splitData.toList() +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt new file mode 100644 index 0000000..33b3341 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt @@ -0,0 +1,66 @@ +package com.remax.visualnovel.extension + + +import com.remax.visualnovel.utils.TimeUtils +import java.util.Calendar + +/** + * Created by HJW on 2022/7/28 + */ + +/** + * 计算年龄 + */ +fun Long?.calculateAge(): Int { + val calendar = Calendar.getInstance() + val yearNow = calendar.get(Calendar.YEAR) + + val birth = birthDateFormatParse.format(this) + calendar.time = TimeUtils.getBirthDate(birth) + val year = yearNow - calendar.get(Calendar.YEAR) + + return year +} + +/** + * 检查年龄是否满18岁 + * @receiver Long + * @return Boolean + */ +fun Long.checkAge(): Boolean { + val calendar = Calendar.getInstance() + val yearNow = calendar.get(Calendar.YEAR) + val monthNow = calendar.get(Calendar.MONTH) + val dayNow = calendar.get(Calendar.DATE) + val birth =birthDateFormatParse.format(this) + calendar.time = TimeUtils.getBirthDate(birth) + val year = yearNow - calendar.get(Calendar.YEAR) + val month = monthNow - calendar.get(Calendar.MONTH) + val day = dayNow - calendar.get(Calendar.DATE) + + return when { + year < 18 -> { + false + } + + year == 18 -> { + when { + month < 0 -> { + false + } + + month == 0 -> { + day >= 0 + } + + else -> { + true + } + } + } + + else -> { + true + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt new file mode 100644 index 0000000..198db50 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt @@ -0,0 +1,148 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentPagerAdapter +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.remax.visualnovel.app.base.BaseCommonNavigatorAdapter +import com.remax.visualnovel.widget.indicator.ViewPager2Helper +import net.lucode.hackware.magicindicator.MagicIndicator +import net.lucode.hackware.magicindicator.ViewPagerHelper +import net.lucode.hackware.magicindicator.buildins.commonnavigator.CommonNavigator + +/** + * Created by HJW on 2023/7/18 + */ + + +/** + * 快速设置MagicIndicator + * @receiver Context 当前上下文 + * @param fragments List + * @param navigationAdapter CommonNavigatorAdapter 传入具体子类实现UI样式 + * @param magicIndicator MagicIndicator + * @param leftPadding Int + * @param rightPadding Int + * @param isAdjustMode Boolean + * @param viewPager ViewPager? 与viewPager绑定 + * @param viewPager2 ViewPager2? 与viewPager2绑定 + * @param currentItem Int + * @param viewPager2PageSelected Function1? + */ +fun Fragment.setMagicIndicator( + fragments: List, + navigationAdapter: BaseCommonNavigatorAdapter, + magicIndicator: MagicIndicator, + leftPadding: Int = 0, + rightPadding: Int = 0, + isAdjustMode: Boolean = false, + currentItem: Int = 0, + isUserInputEnabled: Boolean = true, + viewPager2PageSelected: ((Int) -> Unit)? = null +) { + requireContext().setMagicIndicatorCommon( + fragments, + navigationAdapter, + magicIndicator, + leftPadding, + rightPadding, + isAdjustMode, + currentItem, + isUserInputEnabled, + viewPager2PageSelected + ) + + navigationAdapter.viewPager2?.adapter = object : FragmentStateAdapter(this) { + override fun getItemCount() = navigationAdapter.count + override fun createFragment(position: Int): Fragment = fragments[position] + } + + navigationAdapter.viewPager?.adapter = object : FragmentPagerAdapter(childFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getItem(position: Int): Fragment = fragments[position] + override fun getCount() = fragments.size + } + +} + +fun FragmentActivity.setMagicIndicator( + fragments: List, + navigationAdapter: BaseCommonNavigatorAdapter, + magicIndicator: MagicIndicator, + leftPadding: Int = 0, + rightPadding: Int = 0, + isAdjustMode: Boolean = false, + currentItem: Int = 0, + isUserInputEnabled: Boolean = true, + viewPager2PageSelected: ((Int) -> Unit)? = null +) { + navigationAdapter.viewPager2?.adapter = object : FragmentStateAdapter(this) { + override fun getItemCount() = navigationAdapter.count + override fun createFragment(position: Int): Fragment = fragments[position] + } + + navigationAdapter.viewPager?.adapter = object : FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getItem(position: Int): Fragment = fragments[position] + override fun getCount() = fragments.size + } + + setMagicIndicatorCommon( + fragments, + navigationAdapter, + magicIndicator, + leftPadding, + rightPadding, + isAdjustMode, + currentItem, + isUserInputEnabled, + viewPager2PageSelected + ) + +} + +private fun Context.setMagicIndicatorCommon( + fragments: List, + navigationAdapter: BaseCommonNavigatorAdapter, + magicIndicator: MagicIndicator, + leftPadding: Int = 0, + rightPadding: Int = 0, + isAdjustMode: Boolean = false, + currentItem: Int = 0, + isUserInputEnabled: Boolean = true, + viewPager2PageSelected: ((Int) -> Unit)? = null +) { + val commonNavigator = CommonNavigator(this).apply { + adapter = navigationAdapter + this.isAdjustMode = isAdjustMode + this.leftPadding = leftPadding + this.rightPadding = rightPadding + } + magicIndicator.navigator = commonNavigator + + navigationAdapter.viewPager?.let { + ViewPagerHelper.bind(magicIndicator, it) + if (fragments.isNotEmpty()) { + it.offscreenPageLimit = fragments.size + } + if (currentItem < fragments.size) { + it.doOnPreDraw { _ -> + it.currentItem = currentItem + } + } + } + navigationAdapter.viewPager2?.let { + ViewPager2Helper.bind(magicIndicator, it) { position -> + viewPager2PageSelected?.invoke(position) + } + if (fragments.isNotEmpty()) { + it.offscreenPageLimit = fragments.size + } + if (currentItem < fragments.size) { + it.doOnPreDraw { _ -> + it.currentItem = currentItem + } + } + it.isUserInputEnabled = isUserInputEnabled + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt new file mode 100644 index 0000000..184c864 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.extension + +import androidx.appcompat.app.AppCompatActivity +import com.remax.visualnovel.R + +/** + * Activity打开动画 从下往上 + */ +fun AppCompatActivity.withTransitionFromBottom() { + overridePendingTransition(R.anim.act_slide_in_from_bottom, R.anim.no_anim) +} + + +fun AppCompatActivity.transitionFromBottom() { + overridePendingTransition(R.anim.no_anim, R.anim.act_slide_out_from_bottom) +} + + +fun AppCompatActivity.withTransitionFromAlpha() { + overridePendingTransition(R.anim.dialog_alpha_show, R.anim.no_anim) +} + + +fun AppCompatActivity.transitionFromAlpha() { + overridePendingTransition(R.anim.no_anim, R.anim.dialog_alpha_cancel) +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt new file mode 100644 index 0000000..486720a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt @@ -0,0 +1,52 @@ +package com.remax.visualnovel.extension + +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView +import com.scwang.smart.refresh.layout.SmartRefreshLayout +import kotlin.math.abs + +/** + * Created by HJW on 2022/11/16 + */ + +/** + * 搜索页的RecyclerView 点击无子view的地方回调 + */ +fun RecyclerView.setSearchClosePageEvent(nullViewCallback: () -> Unit) { + addOnItemTouchListener(object : RecyclerView.OnItemTouchListener { + var dx = 0f + var dy = 0f + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.action) { + MotionEvent.ACTION_MOVE -> { + } + + MotionEvent.ACTION_UP -> { + val x = dx - e.x + if (abs(x) < 30) { + val view = rv.findChildViewUnder(e.x, e.y) + if (view == null) { + nullViewCallback.invoke() + } + } + } + + MotionEvent.ACTION_DOWN -> { + dx = e.x + dy = e.y + } + } + return false + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + } + }) +} + +fun SmartRefreshLayout.autoRefreshList(animationOnly: Boolean = false) { + autoRefresh(400, 300, 1f, animationOnly) +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt new file mode 100644 index 0000000..129c486 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import android.content.res.Resources + +/** + * Created by HJW on 2023/3/17 + */ + +/** + * 字体大小不随着系统大小变动 + */ +fun Resources.fixedFontSize(context: Context): Resources { + var resources = this + val newConfig = resources.configuration + val displayMetrics = resources.displayMetrics + if (newConfig.fontScale != 1f) { + newConfig.fontScale = 1f + val configurationContext = context.createConfigurationContext(newConfig) + resources = configurationContext.resources + displayMetrics.scaledDensity = displayMetrics.density * newConfig.fontScale + } + return resources +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt new file mode 100644 index 0000000..8a43edf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt @@ -0,0 +1,141 @@ +package com.remax.visualnovel.extension + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle + +/** + * Created by HJW on 2021/1/6 + */ + + +/*** + * 修改ViewPager 适配器为 + * FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) + FragmentStatePagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) + */ + +/** + * 加载根Fragment + * @param containerViewId 布局id + * @param rootFragment 根fragment + */ +fun Fragment.loadRootFragment(@IdRes containerViewId: Int, rootFragment: Fragment) { + loadFragmentsTransaction(containerViewId, 0, childFragmentManager, rootFragment) +} + +/** + * 加载同级的Fragment + * @param containerViewId 布局id + * @param showPosition 默认显示的角标 + * @param fragments 加载的fragment + */ +fun Fragment.loadFragments( + @IdRes containerViewId: Int, + showPosition: Int = 0, + vararg fragments: Fragment +) { + loadFragmentsTransaction(containerViewId, showPosition, childFragmentManager, *fragments) +} + +/** + * 显示目标fragment,并隐藏其他fragment + * @param showFragment 需要显示的fragment + */ +fun Fragment.showHideFragment(showFragment: Fragment) { + showHideFragmentTransaction(childFragmentManager, showFragment) +} + + +/** + * 加载根Fragment + * @param containerViewId 布局id + * @param rootFragment 根fragment + */ +fun FragmentActivity.loadRootFragment(@IdRes containerViewId: Int, rootFragment: Fragment) { + loadFragmentsTransaction(containerViewId, 0, supportFragmentManager, rootFragment) +} + +/** + * 加载同级的Fragment + * @param containerViewId 布局id + * @param showPosition 默认显示的角标 + * @param fragments 加载的fragment + */ +fun FragmentActivity.loadFragments( + @IdRes containerViewId: Int, + showPosition: Int = 0, + vararg fragments: Fragment +) { + loadFragmentsTransaction(containerViewId, showPosition, supportFragmentManager, *fragments) +} + +/** + * 显示目标fragment,并隐藏其他fragment + * @param showFragment 需要显示的fragment + */ +fun FragmentActivity.showHideFragment(showFragment: Fragment) { + showHideFragmentTransaction(supportFragmentManager, showFragment) +} + +/** + * 使用add+show+hide模式加载fragment + * + * 默认显示位置[showPosition]的Fragment,最大Lifecycle为Lifecycle.State.RESUMED + * 其他隐藏的Fragment,最大Lifecycle为Lifecycle.State.STARTED + * + *@param containerViewId 容器id + *@param showPosition fragments + *@param fragmentManager FragmentManager + *@param fragments 控制显示的Fragments + */ +private fun loadFragmentsTransaction( + @IdRes containerViewId: Int, + showPosition: Int, + fragmentManager: FragmentManager, + vararg fragments: Fragment +) { + if (fragments.isNotEmpty()) { + fragmentManager.beginTransaction().apply { + for (index in fragments.indices) { + val fragment = fragments[index] + add(containerViewId, fragment, fragment.javaClass.name) + if (showPosition == index) { + setMaxLifecycle(fragment, Lifecycle.State.RESUMED) + } else { + hide(fragment) + setMaxLifecycle(fragment, Lifecycle.State.STARTED) + } + } + + }.commit() + } else { + throw IllegalStateException( + "fragments must not empty" + ) + } +} + +/** + * 显示需要显示的Fragment[showFragment],并设置其最大Lifecycle为Lifecycle.State.RESUMED。 + * 同时隐藏其他Fragment,并设置最大Lifecycle为Lifecycle.State.STARTED + * @param fragmentManager + * @param showFragment + */ +private fun showHideFragmentTransaction(fragmentManager: FragmentManager, showFragment: Fragment) { + fragmentManager.beginTransaction().apply { + show(showFragment) + setMaxLifecycle(showFragment, Lifecycle.State.RESUMED) + + //获取其中所有的fragment,其他的fragment进行隐藏 + val fragments = fragmentManager.fragments + for (fragment in fragments) { + if (fragment != showFragment) { + hide(fragment) + setMaxLifecycle(fragment, Lifecycle.State.STARTED) + } + } + }.commit() +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt new file mode 100644 index 0000000..10d015d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt @@ -0,0 +1,100 @@ +package com.remax.visualnovel.extension + +import com.remax.visualnovel.constant.AppStatus +import com.remax.visualnovel.utils.spannablex.utils.dp +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * Created by HJW on 2020/9/10 + */ +fun String?.md5Encode(): String { + if (this == null) { + return "" + } + try { + //获取md5加密对象 + val instance: MessageDigest = MessageDigest.getInstance("MD5") + //对字符串加密,返回字节数组 + val digest: ByteArray = instance.digest(this.toByteArray()) + val sb: StringBuffer = StringBuffer() + for (b in digest) { + //获取低八位有效值 + val i: Int = b.toInt() and 0xff + //将整数转化为16进制 + var hexString = Integer.toHexString(i) + if (hexString.length < 2) { + //如果是一位的话,补0 + hexString = "0$hexString" + } + sb.append(hexString) + } + return sb.toString() + + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } + + return "" +} + +/** + * 处理图片压缩 + * + */ +fun String?.toS3Url(wid: Int, hei: Int? = null, isDp: Boolean = true, isFill: Boolean = hei != null): String { + if (isNullOrEmpty()) return "" + return when { + this.endsWith(".gif") -> { + this + } + + else -> { + val resizeWid = if (isDp) wid.dp else wid + val resizeHei = if (hei != null) { + if (isDp) hei.dp else hei + } else { + resizeWid + } + "$this?x-oss-process=image/resize${if (isFill) ",m_fill" else ""},w_${resizeWid},h_${resizeHei}" + } + } +} + +/** + * 图片新增方向裁剪 + * 以宽为基准 w,1 + * 以高为基准 h,1 + */ +fun String?.toCropS3Url(isWidth: Boolean, radio: Float) = "${this ?: ""}&m_crop=${if (isWidth) "w" else "h"},$radio" + + +// 正则表达式匹配 () ,包括中英文的,不考虑嵌套 +fun String?.extractBrackets(): List { + val regex = Regex("(\\(.*?\\)|(.*?))") + // 按文本顺序提取所有匹配的括号对 + return regex.findAll(this ?: "").map { it.value }.toList() +} + +/** + * 获取云信的ID + */ +fun String?.getNimAccountId(isAI: Boolean) = this + + if (isAI) { + if (AppStatus.isProduct) "@r" else "@r@t" + } else { + if (AppStatus.isProduct) "@u" else "@u@t" + } + + +/** + * 检查长度 + */ +fun String?.checkLength(minLength: Int, allowEmpty: Boolean = false): Boolean { + return when { + allowEmpty && this.isNullOrBlank() -> true + minLength <= 0 && this.isNullOrBlank() -> false + (this?.trim()?.length ?: 0) < minLength -> false + else -> true + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt new file mode 100644 index 0000000..a1f69bc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt @@ -0,0 +1,34 @@ +package com.remax.visualnovel.extension + +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.widget.ui.SwitchView + +/** + * Created by HJW on 2023/11/13 + */ + +/** + * 批量操作此页面 Switch + * @receiver SwitchView + * @param switchChecked Boolean 开关是否打开 null表示不操作 + * @param switchLayouts ViewGroup? SwitchView的相关父布局 + * @param switchIsVisible Boolean SwitchView的父布局是否隐藏 + * @param switchCheckedCallback Function0 开关切换callback + */ +internal fun SwitchView.setSwitchShowAndChecked( + switchChecked: Boolean? = null, + switchLayouts: List? = null, + switchIsVisible: Boolean = false, + switchCheckedCallback: (checked: Boolean) -> Unit +) { + switchLayouts?.forEach { + it.isVisible = switchIsVisible + } + switchChecked?.let { + isChecked = it + } + setPressChanged { + switchCheckedCallback(isChecked) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt new file mode 100644 index 0000000..53e84b7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt @@ -0,0 +1,106 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import android.graphics.LinearGradient +import android.graphics.Shader +import android.text.Layout +import android.text.StaticLayout +import android.view.View +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.constant.Gender +import com.remax.visualnovel.utils.SpanUtils +import com.remax.visualnovel.utils.spannablex.SpanDsl +import com.remax.visualnovel.widget.uitoken.handleUIToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +fun TextView.setUnReadCount(num: Int?) { + val realNum = num ?: 0 + text = if (realNum > 99) "···" else realNum.toString() + isVisible = realNum > 0 +} + +fun TextView.setGenderLeft(gender: Int?) { + when (gender) { + Gender.MALE.value -> drawableLeft(R.mipmap.ic_gender_male) + + Gender.FEMALE.value -> drawableLeft(R.mipmap.ic_gender_female) + + Gender.NONCONFORMING.value -> drawableLeft(R.mipmap.ic_gender_nonconforming) + + Gender.OTHER.value -> drawableLeft(R.mipmap.ic_gender_nonconforming) + } +} + +fun TextView.drawableLeft(resId: Int) { + setCompoundDrawablesRelativeWithIntrinsicBounds(resId, 0, 0, 0) +} + +fun TextView.drawableRight(resId: Int) { + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, resId, 0) +} + +fun Int?.getSexText() = when (this) { + Gender.MALE.value -> CommonApplicationProxy.application.getString(Gender.MALE.txtRes) + + Gender.FEMALE.value -> CommonApplicationProxy.application.getString(Gender.FEMALE.txtRes) + + Gender.NONCONFORMING.value -> CommonApplicationProxy.application.getString(Gender.NONCONFORMING.txtRes) + + Gender.OTHER.value -> CommonApplicationProxy.application.getString(Gender.OTHER.txtRes) + + else -> "" +} + +/** + * 快速根据token设置字体 + * @receiver Context + * @param fontToken Int token资源 + */ +fun SpanDsl.setSpanTypeFace(context: Context, @StringRes fontToken: Int = 0) { + context.handleUIToken(fontToken)?.textFont?.run { + typeface(context.getTextFontTypeface(this.typeFace)) + absoluteSize(this.textFontSize.toInt(), false) + } +} + +fun TextView.showGradientColor(colors: IntArray, content: String? = null) { + SpanUtils.with(this).append(content ?: "").setShader( + LinearGradient( + 0f, + 0f, + paint.measureText(content ?: ""), + 0f, + colors, + null, + Shader.TileMode.CLAMP + ) + ).create() +} + +fun TextView.calculateLineCount(content: String?, availableWidth: Int, introAll: View) { + introAll.isVisible = false + CoroutineScope(Dispatchers.IO).launch { + val staticLayout = StaticLayout.Builder + .obtain(content ?: "", 0, content?.length ?: 0, paint, availableWidth) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) + .setIncludePad(includeFontPadding) + .build() + val lineCount = staticLayout.lineCount + withContext(Dispatchers.Main) { + if (lineCount > 3) { + maxLines = 3 + introAll.isVisible = true + } + text = content + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt new file mode 100644 index 0000000..1f8574d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt @@ -0,0 +1,116 @@ +package com.remax.visualnovel.extension + +import android.animation.ObjectAnimator +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.MarginLayoutParams +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.EditText +import androidx.core.view.updateLayoutParams +import com.remax.visualnovel.utils.KeyboardUtils +import com.google.android.material.appbar.AppBarLayout + +/** + * Created by HJW on 2021/4/1 + */ + +/** + * 标题arrow旋转动画 + */ +fun View.navRotationOpen(isOpen: Boolean) { + val end: Float = if (isOpen) 180f else 0.toFloat() + val start: Float = if (isOpen) 0f else 180.toFloat() + ObjectAnimator.ofFloat(this, "rotation", start, end).setDuration(200).start() +} + +/** + * 在editText上显示键盘 + */ +fun View.showKeyboard() { + (this as? EditText)?.run { + setSelection(text.toString().length) + } + KeyboardUtils.showSoftInput(this) +} + +fun View.setMargin(marginStart: Int? = null, topMargin: Int? = null, marginEnd: Int? = null, bottomMargin: Int? = null) { + (layoutParams as? MarginLayoutParams)?.let { + updateLayoutParams { + marginStart?.run(this::setMarginStart) + marginEnd?.run(this::setMarginEnd) + if (topMargin != null) this.topMargin = topMargin + if (bottomMargin != null) this.bottomMargin = bottomMargin + } + } +} + +fun View.setSize(width: Int? = null, height: Int? = null): Pair { + if (layoutParams == null) { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + updateLayoutParams { + width?.let { + this.width = it + } + height?.let { + this.height = it + } + } + return Pair(width, height) +} + +fun View.setAllParentClipChildren() { + if (parent is ViewGroup && parent !is AppBarLayout) { + (parent as? ViewGroup)?.run { + clipToPadding = false + clipChildren = false + setAllParentClipChildren() + } + } +} + +fun View.inRangeOfView(ev: MotionEvent): Boolean { + val location = IntArray(2) + this.getLocationOnScreen(location) + val x = location[0] + val y = location[1] + return !(ev.x < x || ev.x > x + this.width || ev.y < y || ev.y > y + this.height) +} + +fun View?.isShouldHideKeyboard(event: MotionEvent): Boolean { + if (this != null) { //判断得到的焦点控件是否包含EditText + val l = intArrayOf(0, 0) + this.getLocationInWindow(l) + val left = l[0] + //得到输入框在屏幕中上下左右的位置 + val top = l[1] + val bottom = top + this.height + val right = left + this.width + return !(event.x > left && event.x < right && event.y > top && event.y < bottom) + } + // 如果焦点不是EditText则忽略 + return false +} + + +fun View.translationYObjectAnimator( + translationY: Float, + duration: Long, + onEnd: (() -> Unit)? = null +) { + val animatorY = ObjectAnimator.ofFloat(this, "translationY", translationY) + + animatorY.apply { + this.duration = duration + interpolator = AccelerateDecelerateInterpolator() + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + onEnd?.invoke() + } + }) + } + animatorY.start() +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt new file mode 100644 index 0000000..f03a706 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.extension + +import android.app.Activity +import android.view.View +import com.remax.visualnovel.utils.StatusBarUtils + +/** + * Created by HJW on 2023/3/20 + */ + +@Suppress("DEPRECATION") +fun Activity.changeStatusBarVisibility(isLand: Boolean) { + if (isLand) { + // 隐藏状态栏和导航栏,拉出状态栏和导航栏显示一会儿后消失。 + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + } else { + //显示状态栏和导航栏 + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE + StatusBarUtils.setTransparent(this) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt new file mode 100644 index 0000000..0c83201 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt @@ -0,0 +1,259 @@ +package com.remax.visualnovel.extension.glide + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.scale +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.webp.decoder.WebpDrawable +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.remax.visualnovel.extension.toS3Url +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.glidetransformation.CropRectTransformation +import jp.wasabeef.glide.transformations.RoundedCornersTransformation +import timber.log.Timber +import ua.anatolii.graphics.ninepatch.NinePatchChunk + + +fun ImageView.loadGranularRoundedCorners( + url: String?, + topLeft: Int = 0, + topRight: Int = 0, + bottomRight: Int = 0, + bottomLeft: Int = 0, + cache: Boolean = false +) { + Glide.with(context) + .load(url) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .apply( + RequestOptions.bitmapTransform( + MultiTransformation( + CenterCrop(), + GranularRoundedCorners( + topLeft.dp.toFloat(), + topRight.dp.toFloat(), + bottomRight.dp.toFloat(), + bottomLeft.dp.toFloat() + ) + ) + ) + ).into(this) +} + +fun ImageView.loadNoCenterCrop(url: String?) { + visibility = View.VISIBLE + Glide.with(context) + .load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.loadFitCenter(url: String?, cache: Boolean = true) { + visibility = View.VISIBLE + Glide.with(context) + .load(url) + .skipMemoryCache(!cache) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .into(this) +} + + +fun ImageView.loadWebp(url: String?) { + isVisible = true + Glide.with(context) + .load(url) + .addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + return false + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + (resource as? WebpDrawable)?.loopCount = -1 + return false + } + }) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.load(url: String?) { + visibility = View.VISIBLE + Glide.with(context) + .load(url) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun TextView.loadNinePatch(nineImageUrl: String?) { + Glide.with(context) + .asBitmap() + .load(nineImageUrl) + .into(object : CustomTarget() { + override fun onResourceReady( + bitmap: Bitmap, + transition: Transition? + ) { + val drawable = NinePatchChunk.create9PatchDrawable(context, bitmap, null) + Timber.e("点9图 drawable :$drawable") + background = drawable + } + + override fun onLoadCleared(placeholder: Drawable?) { + + } + }) +} + +fun ImageView.load(drawable: Drawable?) { + visibility = View.VISIBLE + Glide.with(context) + .load(drawable) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.loadToOriginal(url: String?, width: Int, height: Int, cache: Boolean = true) { + Glide.with(context) + .load(url) + .override(width, height) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .into(this) +} + +fun ImageView.loadAndCircleCrop(url: String?) { + Glide.with(context).load(url).diskCacheStrategy(DiskCacheStrategy.AUTOMATIC).circleCrop().into(this) +} + +fun ImageView.loadAndRoundCorner( + url: String?, + radius: Int, + margin: Int = 0, + cache: Boolean = true, + cornerType: RoundedCornersTransformation.CornerType = RoundedCornersTransformation.CornerType.ALL +) { + val transformation = MultiTransformation( + CenterCrop(), + RoundedCornersTransformation(radius.dp, margin, cornerType) + ) + Glide.with(context).load(url) + .skipMemoryCache(!cache) + .transform(transformation) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .into(this) +} + +fun ImageView.loadResAndRoundCorner( + res: Int, + radius: Int, + margin: Int = 0, + cornerType: RoundedCornersTransformation.CornerType = RoundedCornersTransformation.CornerType.ALL +) { + val transformation = MultiTransformation( + CenterCrop(), + RoundedCornersTransformation(radius.dp, margin, cornerType) + ) + Glide.with(context).load(res) + .transform(transformation) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.loadAndRoundCornerToOriginal(url: String?, radius: Int, margin: Int = 0, width: Int, height: Int) { + val transformation = MultiTransformation( + CenterCrop(), + RoundedCornersTransformation(radius.dp, margin) + ) + Glide.with(context) + .load(url) + .override(width, height) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .centerCrop() + .transform(transformation) + .into(this) +} + +fun ImageView.loadAndCropTransformation( + url: String?, + width: Int, + height: Int, + cropXType: CropRectTransformation.CropXType = CropRectTransformation.CropXType.TOP, + cropYType: CropRectTransformation.CropYType = CropRectTransformation.CropYType.CENTER, + radius: Int = 0 +) { + Glide.with(context) + .load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .apply( + RequestOptions.bitmapTransform( + MultiTransformation( + CropRectTransformation(width, height, cropXType, cropYType), + RoundedCornersTransformation(radius.dp, 0) + ) + ) + ) + .into(this) +} + +fun ImageView.loadAndCropTransformation( + @DrawableRes res: Int, + width: Int, + height: Int, + cropXType: CropRectTransformation.CropXType = CropRectTransformation.CropXType.TOP, + cropYType: CropRectTransformation.CropYType = CropRectTransformation.CropYType.CENTER, + radius: Int = 0 +) { + Glide.with(context) + .load(ContextCompat.getDrawable(context, res)) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .apply( + RequestOptions.bitmapTransform( + MultiTransformation( + CropRectTransformation(width, height, cropXType, cropYType), + RoundedCornersTransformation(radius.dp, 0) + ) + ) + ) + .into(this) +} + + +fun ImageView.loadCircleAvatar(url: String?, wid: Int, hei: Int? = null) { + this.isVisible = true + val processImage = url?.toS3Url(wid, hei, isDp = false, isFill = false) + Glide.with(context) + .load(processImage) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt new file mode 100644 index 0000000..3c8dd3b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt @@ -0,0 +1,23 @@ +package com.remax.visualnovel.extension.ui + +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.ui.buttons.IconButtonView +import com.remax.visualnovel.widget.uitoken.changeTextColor + +/** + * Created by HJW on 2023/7/24 + */ + +fun IconButtonView.setFavorite(userFavorite: Boolean) { + if (userFavorite) { + setText(R.string.icon_star_fill) + changeTextColor { + textUIColorToken = context.getString(R.string.color_warning_gradient_normal) + } + } else { + setText(R.string.icon_star) + changeTextColor { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt new file mode 100644 index 0000000..e7a5fc2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt @@ -0,0 +1,67 @@ +package com.remax.visualnovel.manager + + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.widget.Toast +import java.lang.ref.SoftReference + +/** + * Created by HJW on 2021/1/7 + */ +object CustomToastManager { + private var toastReference: SoftReference? = null + + private var isShowingToast = false + + class MyHandler : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + when (msg.what) { + 0 -> { + isShowingToast = false + } + } + } + } + + private lateinit var handler: MyHandler + + fun showToast(context: Context, content: String?, isLong: Boolean = false) { + if (content.isNullOrBlank()) return + if (toastReference == null || toastReference?.get() == null) { + val toast = createToast(context, content) + toastReference = SoftReference(toast) + } + toastReference?.get()?.let { + if (!isShowingToast) { + isShowingToast = true + it.setText(content) + it.duration = if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + it.show() + val time = 1000L * 2 + if (this::handler.isInitialized) { + handler.sendEmptyMessageDelayed(0, time) + } else { + handler = MyHandler() + handler.sendEmptyMessageDelayed(0, time) + } + } else { + it.setText(content) + } + } + } + + fun hideToast() { + toastReference?.get()?.cancel() + } + + private fun createToast(context: Context, content: String): Toast { + handler = MyHandler() + return Toast.makeText(context, content, Toast.LENGTH_SHORT) + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt new file mode 100644 index 0000000..1d21f78 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt @@ -0,0 +1,35 @@ +package com.remax.visualnovel.manager.login + +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.repository.mmkv.UserLocalRepository + + +/** + * Created by HJW on 2023/3/17 + * + * 登录要保存/读取的相关数据 + */ +class LoginInfoSave { + + /** + * 保存登录的user + */ + fun putUser(user: User?) { + UserLocalRepository.localUser = user + } + + fun getUser() = UserLocalRepository.localUser + + /** + * 保存token + */ + fun putToken(token: String?) { + UserLocalRepository.token = token ?: "" + } + + fun getToken(): String { + return UserLocalRepository.token + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt new file mode 100644 index 0000000..468b199 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt @@ -0,0 +1,86 @@ +package com.remax.visualnovel.manager.login + +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.event.model.OnLoginEvent +import com.remax.visualnovel.event.model.tab.MainTab +import com.remax.visualnovel.utils.Routers +import com.remax.visualnovel.ui.main.MainActivity + +/** + * Created by HJW on 2025/7/11 + */ +object LoginManager { + + private val loginInfoSave by lazy { + LoginInfoSave() + } + + var user: User? = null + set(value) { + loginInfoSave.putUser(value) + field = value + } + + var token: String? = null + set(value) { + loginInfoSave.putToken(value) + field = value + } + + val isLogin: Boolean + get() = user != null + + val isVip: Boolean + get() = user?.isMember == true + + fun isMyself(userId: String?): Boolean { + return isLogin && userId == user?.userId + } + + fun init() { + user = loginInfoSave.getUser() + token = loginInfoSave.getToken() + } + + /** + * 退出登录 + */ + fun logout() { + user = null + token = null + EventDefineOfUserEvents.onLoginStatusChanged().post(OnLoginEvent(OnLoginEvent.LOGOUT)) + MainActivity.start(MainTab.TAB_BOOKS) + } + + /** + * 检查登录状态 + */ + fun checkLogin(callback: (() -> Unit)? = null) { + if (isLogin) { + callback?.invoke() + } else { + // 未登录授权 总是去登录页面 + Routers.navigation(Routers.LOGIN) + } + } + + fun putUser(user: User?) { + this.user = user + } + + fun putToken(token: String?) { + this.token = token + } + + + /*private val messageRepository by lazy { + MessageRepository(ServiceFactory.createService()) + } + + fun refreshUnreadCount() { + CoroutineScope(Dispatchers.Main).launch { + messageRepository.getMessageStat() + } + }*/ +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt new file mode 100644 index 0000000..291e44a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.entity.response.base.Response +import javax.inject.Inject + + +class BooksRepository @Inject constructor(private val bookService: BookService) : BaseRepository() { + suspend fun getBooks(): Response { + return bookService.getBooks() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt new file mode 100644 index 0000000..3dacf11 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.repository.api + + +import com.remax.visualnovel.api.service.DictService +import com.remax.visualnovel.repository.api.base.BaseRepository +import javax.inject.Inject + +/** + * Created by HJW on 2022/11/15 + */ +class DictRepository @Inject constructor(private val dictService: DictService) : BaseRepository() { + + /*suspend fun getChatBubbleList(aiId: String) = executeHttp { + dictService.getChatBubbleList(AIIDRequest(aiId)) + } + + suspend fun getAIDict() = executeHttp { + dictService.getAIDict() + } + + suspend fun getGiftDict() = executeHttp(false) { dictService.getGiftDict() } + + suspend fun getAIChatModel() = executeHttp { dictService.getAIChatModel() }*/ + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt new file mode 100644 index 0000000..a6d915f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.entity.response.base.Response +import javax.inject.Inject + + +class HistoryRepository @Inject constructor(private val bookService: BookService) : BaseRepository() { + suspend fun getBooks(): Response { + return bookService.getBooks() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt new file mode 100644 index 0000000..d14ae23 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt @@ -0,0 +1,50 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.api.service.LoginService +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.entity.request.PlatformAccountVerifyDTO +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.event.model.OnLoginEvent +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 用户相关 + */ +open class LoginRepository @Inject constructor( + private val loginService: LoginService +) : BaseRepository() { + + suspend fun checkUserNickname(nickname: String?,exUserId:String?) = executeHttp { + val request = CompleteUserInfoInput(nickname = nickname, exUserId = exUserId) + loginService.checkUserNickname(request) + } + + suspend fun platformThirdVerify(thirdToken: String, thirdType: String) = executeHttp { + loginService.platformThirdVerify(PlatformAccountVerifyDTO(thirdToken, thirdType)) + }.transformResult({ + if (it?.isLogin == true) { + LoginManager.putToken(it.token) + } + }) + + /** + * 退出登录 + */ + suspend fun logout(): Response = executeHttp { + loginService.logout() + }.transformResult({ + LoginManager.logout() + }) + + suspend fun register(request: CompleteUserInfoInput) = executeHttp { + loginService.register(request) + }.transformResult({ + EventDefineOfUserEvents.onUserInfoChanged().post(null) + }) + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt new file mode 100644 index 0000000..34c3781 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.entity.response.base.Response +import javax.inject.Inject + + +class ManagasRepository @Inject constructor(private val bookService: BookService) : BaseRepository() { + suspend fun getBooks(): Response { + return bookService.getBooks() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt new file mode 100644 index 0000000..5c68926 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.repository.api + + +import com.remax.visualnovel.api.service.MessageService +import com.remax.visualnovel.repository.api.base.BaseRepository +import javax.inject.Inject + + +/** + * Created by HJW on 2022/11/15 + */ +class MessageRepository @Inject constructor(private val messageService: MessageService) : BaseRepository() { + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt new file mode 100644 index 0000000..ac465cb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt @@ -0,0 +1,58 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.api.service.UserService +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 用户相关 + */ +open class UserRepository @Inject constructor( + private val userService: UserService +) : BaseRepository() { + + suspend fun signToday() = executeHttp { + userService.signToday() + } + + /*suspend fun getSignList() = executeHttp { + userService.getSignList() + }*/ + + suspend fun updateUserInfo(request: CompleteUserInfoInput) = executeHttp { + userService.updateUserInfo(request) + }.transformResult({ + EventDefineOfUserEvents.onUserInfoChanged().post(null) + }) + + /** + * 获取当前登录用户的基本信息,并且更新本地信息 + */ + suspend fun getMyBaseInfo() = executeHttp { + userService.getMyBaseInfo() + }.transformResult({ + LoginManager.putUser(it) + }) + + suspend fun getMyCharactersList() = executeHttp { + userService.getMyCharactersList() + } + + suspend fun deleteAccount() = executeHttp { + userService.deleteAccount() + }.transformResult({ + LoginManager.logout() + }) + + /** + * 获取当前登录用户的网易云信信息 + */ + /*suspend fun getNimInfo() = executeHttp(false) { + userService.getNimInfo() + }*/ +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt new file mode 100644 index 0000000..030609f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt @@ -0,0 +1,94 @@ +package com.remax.visualnovel.repository.api.base + + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.constant.StatusCode +import com.remax.visualnovel.entity.response.base.ApiEmptyResponse +import com.remax.visualnovel.entity.response.base.ApiFailedResponse +import com.remax.visualnovel.entity.response.base.ApiSuccessResponse +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.manager.login.LoginManager +import timber.log.Timber + +open class BaseRepository { + + /** + * 如果不需要检查登录,那么在未登录的情况下 直接返回Success + * @param checkLogin 检查登录,默认都需要检查 + */ + suspend fun executeHttp(checkLogin: Boolean = true, block: suspend () -> Response): Response { + return if (!checkLogin) { + if (LoginManager.isLogin) { + execute(block) + } else { + ApiSuccessResponse() + } + } else { + execute(block) + } + } + + private suspend fun execute(block: suspend () -> Response): Response { + return try { + val data = block.invoke() + handleHttpOk(data) + } catch (e: Exception) { + handleHttpError(e) + } + } + + /** + * 非后台返回错误,捕获到的异常 + */ + private fun handleHttpError(e: Throwable): ApiFailedResponse { + Timber.e("responseAsync error -> ${e.localizedMessage}") + val errorMsg = CommonApplicationProxy.application.getString(R.string.your_network_error) + return ApiFailedResponse(errorMsg = errorMsg) + } + + /** + * http返回200,还要判断后端业务层isSuccess + */ + private fun handleHttpOk(response: Response): Response { + return when { + //后端业务正常 + response.isOk -> { + getHttpSuccessResponse(response) + } + + //登录超时 + response.errorCode == StatusCode.TOKEN_EXPIRED.code -> { + CommonApplicationProxy.application.toast(response.errorMsg) + LoginManager.logout() + ApiFailedResponse(response.errorCode, response.errorMsg) + } + + //余额不足 + response.errorCode == StatusCode.INSUFFICIENT_BALANCE.code -> { + /*WalletManager.refreshWallet() + WalletManager.showChargeDialog()*/ + ApiFailedResponse(response.errorCode, response.errorMsg) + } + + else -> { + ApiFailedResponse(response.errorCode, response.errorMsg) + } + } + } + + /** + * 成功和数据为空的处理 + */ + private fun getHttpSuccessResponse(response: Response): Response { + val data = response.data +// return if (data == null || data is List<*> && (data as List<*>).isEmpty()) { + return if (data == null) { + ApiEmptyResponse() + } else { + ApiSuccessResponse(response.data) + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt new file mode 100644 index 0000000..3578c9b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt @@ -0,0 +1,21 @@ +package com.remax.visualnovel.repository.mmkv + +import com.remax.visualnovel.utils.mmkv.MMKVOwner +import com.remax.visualnovel.utils.mmkv.mmkvBool + +/** + * Created by HJW on 2022/8/30 + * 存储设备的本地数据,与登录用户无关 + */ +object SystemRepository : MMKVOwner("system") { + + /** + * 心动朋友列表页首次说明 + */ + var heartBeatDesc by mmkvBool() + + + var chatBubbleBadge by mmkvBool() + var chatBackgroundBadge by mmkvBool() + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt new file mode 100644 index 0000000..d4330f8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.repository.mmkv + +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.utils.mmkv.MMKVOwner +import com.remax.visualnovel.utils.mmkv.mmkvParcelable +import com.remax.visualnovel.utils.mmkv.mmkvString + +/** + * Created by HJW on 2022/8/22 + * 登录用户相关数据 + */ +object UserLocalRepository : MMKVOwner("user") { + + var token by mmkvString("") + + var localUser by mmkvParcelable() + var userOriginalJson by mmkvString("") +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt new file mode 100644 index 0000000..d871d8b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt @@ -0,0 +1,221 @@ +package com.remax.visualnovel.ui.main + +import android.os.Build +import androidx.activity.addCallback +import androidx.activity.viewModels +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.AppCompatImageView +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.alibaba.android.arouter.facade.annotation.Route +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.BaseBindingActivity +import com.remax.visualnovel.databinding.ActivityMainBinding +import com.remax.visualnovel.event.model.OnLoginEvent +import com.remax.visualnovel.event.model.tab.ContactTab +import com.remax.visualnovel.event.model.tab.MainTab +import com.remax.visualnovel.event.model.tab.OnTabChangedEvent +import com.remax.visualnovel.extension.launchWithRequest +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.Routers +import com.remax.visualnovel.utils.StatusBarUtils +import com.hjq.permissions.XXPermissions +import com.hjq.permissions.permission.PermissionLists +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUIEvents +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import com.remax.visualnovel.ui.main.actor.ActorListFragment +import com.remax.visualnovel.ui.main.book.BookListFragment +import com.remax.visualnovel.ui.main.history.HistoryFragment +import com.remax.visualnovel.ui.main.managa.MangaListFragment +import dagger.hilt.android.AndroidEntryPoint + + + +@AndroidEntryPoint +@Route(path = Routers.MAIN) +class MainActivity : BaseBindingActivity() { + + private val mainViewModel by viewModels() + + override fun initView() { + StatusBarUtils.setStatusBarAndNavBarIsLight(this, false) + StatusBarUtils.setTransparent(this) + + onBackPressedDispatcher.addCallback(this) { + if (mainViewModel.canBack) { + finish() + } else { + toast(getString(R.string.close_app_hint)) + mainViewModel.startCanBackTimer() + } + } + + with(binding) { + with(viewPager2) { + setMargin(topMargin = StatusBarUtils.statusBarHeight) + + val fragments = listOf( + BookListFragment.newInstance(), + MangaListFragment.newInstance(), + ActorListFragment.newInstance(), + HistoryFragment.newInstance() + ) + + offscreenPageLimit = fragments.size + isUserInputEnabled = false + adapter = object : FragmentStateAdapter(this@MainActivity) { + override fun getItemCount() = fragments.size + override fun createFragment(position: Int): Fragment = fragments[position] + } + } + + changeViewPagerIndex(MainTab.TAB_BOOKS) + setOnClick(bookItem, mangaItem, actorItem, historyItem) { + when (this) { + bookItem -> changeViewPagerIndex(MainTab.TAB_BOOKS) + mangaItem -> changeViewPagerIndex(MainTab.TAB_MANGAS) + actorItem -> changeViewPagerIndex(MainTab.TAB_ACTORS) + historyItem -> changeViewPagerIndex(MainTab.TAB_HISTORY) + } + } + } + } + + override fun initData() { + refreshBaseInfo() + launchWithRequest({ + // TODO load init datas by model + //mainViewModel.getAllRole(true) + }) + loginNim() + //chkScheme(intent) + } + + override fun onDestroy() { + super.onDestroy() + EventDefineOfUserEvents.onLoginStatusChanged().removeObserver(loginObserver) + } + + private val loginObserver = androidx.lifecycle.Observer { + if (it?.isLogin() == true) { + loginNim() + checkRegister() + } + launchWithRequest({ + //mainViewModel.getAllRole(true) + }) + } + + override fun subscribeUi() { + + } + + + + /** + * 新建账号需要完善信息 + */ + private fun checkRegister() { + if (LoginManager.user?.cpUserInfo == true) { + Routers.navigation(Routers.REGISTER) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + XXPermissions.with(this).permission(PermissionLists.getPostNotificationsPermission()).request(null) + } + } + } + + private fun refreshBaseInfo() { + if (LoginManager.isLogin) { + launchWithRequest({ + mainViewModel.getMyBaseInfo() + }) + } + } + + /** + * 登录网易云信 + */ + private fun loginNim() { + /*launchAndCollect({ + mainViewModel.getNimInfo() + }) { + onSuccess = { + it?.run { + NimManager.login(this) + } + LoginManager.refreshUnreadCount() + } + } + launchWithRequest(mainViewModel::getNimInfo)*/ + } + + + + private fun changeViewPagerIndex(positionTab: MainTab) { + var go = positionTab.checkLogin + if (go) { + LoginManager.checkLogin { + go = false + } + } + if (go) return + + binding.run { + viewPager2.setCurrentItem(positionTab.index, false) + + val tabItemUIList = listOf( + Pair( + MainTab.TAB_BOOKS, + TabItemUI(bookItem, R.mipmap.main_tab_book_on, R.mipmap.main_tab_book_off) + ), + Pair( + MainTab.TAB_MANGAS, + TabItemUI(mangaItem, R.mipmap.main_tab_manga_on, R.mipmap.main_tab_manga_off) + ), + Pair( + MainTab.TAB_ACTORS, + TabItemUI(actorItem, R.mipmap.main_tab_actor_on, R.mipmap.main_tab_actor_off) + ), + Pair(MainTab.TAB_HISTORY, + TabItemUI(historyItem, R.mipmap.main_tab_history_on, R.mipmap.main_tab_history_off)) + ) + + tabItemUIList.forEach { + if (it.first.index == positionTab.index) { + it.second.item.setImageResource(it.second.selectRes) + } else { + it.second.item.setImageResource(it.second.unselectRes) + } + } + } + + + } + + companion object { + fun start(jumpItem: MainTab = MainTab.TAB_BOOKS, chatItem: ContactTab? = null) { + fun go() { + Routers.navigation(Routers.MAIN) + EventDefineOfUIEvents.onHomeTabChanged().post(OnTabChangedEvent(jumpItem, chatItem)) + } + // chat和me的tab需要检查登录权限 + if (jumpItem.checkLogin) { + LoginManager.checkLogin { + go() + } + } else { + go() + } + } + } + + data class TabItemUI( + val item: AppCompatImageView, + @DrawableRes val selectRes: Int, + @DrawableRes val unselectRes: Int, + ) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt new file mode 100644 index 0000000..0b98661 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.ui.main + +/** + * Created by HJW on 2025/7/14 + */ +import androidx.lifecycle.viewModelScope +import com.remax.visualnovel.app.viewmodel.base.UserViewModel +import com.remax.visualnovel.entity.response.AIDict +import com.remax.visualnovel.repository.api.DictRepository +import com.remax.visualnovel.utils.TimeUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor(private val dictRepository: DictRepository) : UserViewModel() { + + var aiDict: AIDict? = null + private set + + private val _aiDictFlow = MutableSharedFlow() + val aiDictFlow = _aiDictFlow.asSharedFlow() + + /*suspend fun getAllRole(sendFlow: Boolean = false) = dictRepository.getAIDict().transformResult({ + aiDict = it + if (sendFlow) { + _aiDictFlow.emit(true) + } + })*/ + + var canBack = false + fun startCanBackTimer() { + viewModelScope.launch { + canBack = true + delay(TimeUtils.ONE_SECOND * 3) + canBack = false + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt new file mode 100644 index 0000000..443f6d0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.ui.main.book + +import android.os.Bundle +import androidx.fragment.app.viewModels +import com.alibaba.android.arouter.facade.annotation.Route +import com.alibaba.android.arouter.launcher.ARouter +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.app.base.BaseBindingFragment +import com.remax.visualnovel.databinding.FragmentMainBookBinding +import com.remax.visualnovel.utils.Routers +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue + + + +@AndroidEntryPoint +@Route(path = Routers.ROUTE_FRAG_BOOKLIST) +class BookListFragment : BaseBindingFragment() { + + private val contactViewModel by viewModels() + + override fun onCreated(bundle: Bundle?) { + setUI() + } + + override fun backgroundColorType(): BgColorType { + return BgColorType.TRANSPARENT + } + + private fun setUI() { + with(binding) { + + } + } + + companion object { + fun newInstance(): BookListFragment { + return ARouter.getInstance().build(Routers.ROUTE_FRAG_BOOKLIST) + .navigation() as BookListFragment + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt new file mode 100644 index 0000000..961f7ff --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.ui.main.book + + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.app.viewmodel.base.BaseViewModel +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.BooksRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + + + +@HiltViewModel +class BookListViewModel @Inject constructor(private val chatRepository: BooksRepository) : BaseViewModel() { + + private val _msgStatFlow = MutableSharedFlow>() + val msgStatFlow = _msgStatFlow.asSharedFlow() + + suspend fun getMessageStat() { + _msgStatFlow.emit(chatRepository.getBooks()) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt new file mode 100644 index 0000000..408877c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.ui.main.history + +import android.os.Bundle +import androidx.fragment.app.viewModels +import com.alibaba.android.arouter.facade.annotation.Route +import com.alibaba.android.arouter.launcher.ARouter +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.app.base.BaseBindingFragment +import com.remax.visualnovel.databinding.FragmentMainHistoryBinding +import com.remax.visualnovel.utils.Routers +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue + + + +@AndroidEntryPoint +@Route(path = Routers.ROUTE_FRAG_HISTORY) +class HistoryFragment : BaseBindingFragment() { + + private val contactViewModel by viewModels() + + override fun onCreated(bundle: Bundle?) { + setUI() + } + + override fun backgroundColorType(): BgColorType { + return BgColorType.TRANSPARENT + } + + private fun setUI() { + with(binding) { + + } + } + + companion object { + fun newInstance(): HistoryFragment { + return ARouter.getInstance().build(Routers.ROUTE_FRAG_HISTORY) + .navigation() as HistoryFragment + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt new file mode 100644 index 0000000..bb7c9d5 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.ui.main.history + + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.app.viewmodel.base.BaseViewModel +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.BooksRepository +import com.remax.visualnovel.repository.api.HistoryRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + + + +@HiltViewModel +class HistoryViewModel @Inject constructor(private val chatRepository: HistoryRepository) : BaseViewModel() { + + private val _msgStatFlow = MutableSharedFlow>() + val msgStatFlow = _msgStatFlow.asSharedFlow() + + suspend fun getMessageStat() { + _msgStatFlow.emit(chatRepository.getBooks()) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt new file mode 100644 index 0000000..52be620 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.ui.main.managa + +import android.os.Bundle +import androidx.fragment.app.viewModels +import com.alibaba.android.arouter.facade.annotation.Route +import com.alibaba.android.arouter.launcher.ARouter +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.app.base.BaseBindingFragment +import com.remax.visualnovel.databinding.FragmentMainMangaBinding +import com.remax.visualnovel.utils.Routers +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue + + + +@AndroidEntryPoint +@Route(path = Routers.ROUTE_FRAG_MANGALIST) +class MangaListFragment : BaseBindingFragment() { + + private val contactViewModel by viewModels() + + override fun onCreated(bundle: Bundle?) { + setUI() + } + + override fun backgroundColorType(): BgColorType { + return BgColorType.TRANSPARENT + } + + private fun setUI() { + with(binding) { + + } + } + + companion object { + fun newInstance(): MangaListFragment { + return ARouter.getInstance().build(Routers.ROUTE_FRAG_MANGALIST) + .navigation() as MangaListFragment + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt new file mode 100644 index 0000000..f6d9cbb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.ui.main.managa + + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.app.viewmodel.base.BaseViewModel +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.ManagasRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + + + +@HiltViewModel +class MangaListViewModel @Inject constructor(private val chatRepository: ManagasRepository) : BaseViewModel() { + + private val _msgStatFlow = MutableSharedFlow>() + val msgStatFlow = _msgStatFlow.asSharedFlow() + + suspend fun getMessageStat() { + _msgStatFlow.emit(chatRepository.getBooks()) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt new file mode 100644 index 0000000..009d941 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt @@ -0,0 +1,23 @@ +package com.remax.visualnovel.utils + +import android.annotation.SuppressLint +import android.provider.Settings +import com.remax.visualnovel.app.base.app.CommonApplicationProxy + +/** + * Created by HJW on 2023/5/10 + */ +class AppUtils { + + companion object { + private var androidId = "" + + @SuppressLint("HardwareIds") + fun getAndroidID() = androidId.ifEmpty { + val id = Settings.Secure.getString(CommonApplicationProxy.application.contentResolver, Settings.Secure.ANDROID_ID) + androidId = id + if (id.isNullOrEmpty()) "" else id + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt new file mode 100644 index 0000000..85beb8f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt @@ -0,0 +1,137 @@ +package com.remax.visualnovel.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.util.regex.Matcher +import java.util.regex.Pattern + +class EpalUtils { + + companion object { + /** + * 自动适配单位 + * + * @param size + * @return + */ + fun formatNumberAutoSize(size: Long, pattern: String = "####.0"): String { + if (size < 1000) { + return size.toString() + } + val format = DecimalFormat(pattern) + format.groupingSize = 0 + format.roundingMode = RoundingMode.FLOOR + format.maximumFractionDigits = 1 + if (size < 1000000) { + val bsize = size.toDouble() + val ksize = bsize / 1000 + var str = format.format(ksize) + str += "K" + return str + } else if (size < 1000000000) { + val bsize = size.toDouble() + val ksize = bsize / 1000000 + var str = format.format(ksize) + str += "M" + return str + } else { + val bsize = size.toDouble() + val ksize = bsize / 1000000000 + var str = format.format(ksize) + str += "B" + return str + } + } + + fun parseEditBuff(amount: String): Long { + runCatching { + amount.replace(",", ".").toDouble() + }.onSuccess { + val b1 = BigDecimal(it.toString()) + val b2 = BigDecimal("100.0") + return (b1.multiply(b2).toDouble()).toLong() + }.onFailure { + return 0 + } + return 0 + } + + /** + * 自动适配单位 + * + * @param size + * @return + */ + fun formatNumberAutoSize(size: Int?): String { + return formatNumberAutoSize((size?:0).toLong()) + } + + fun getRandom(): String { + return System.currentTimeMillis().toString() + List(3) { ('a'..'z').random() }.joinToString("") + } + + /** + * 去掉字符串中的前后空格符、换行符 + */ + fun replaceBlank(str: String?): String { + var dest = "" + if (!str.isNullOrBlank()) { + dest = str.trim() + val p: Pattern = Pattern.compile("\t|\r|\n") + val m: Matcher = p.matcher(dest) + dest = m.replaceAll("") + } + return dest + } + + /** + * 去掉字符串左右换行 + * + * @param str 原字符串 + * @return 转换后的字符串 + */ + fun trimN(str: String): String { + var len = str.length + var st = 0 + val text = str.toCharArray() + while (st < len && text[st] <= '\r') { + st++ + } + while (st < len && text[len - 1] <= '\r') { + len-- + } + return if (st > 0 || len < str.length) str.substring(st, len) else str + } + + fun viewToBitmap(view: View): Bitmap? { + var width = view.width + var height = view.height + view.clearFocus() + if (width <= 0 || height <= 0) { + val specSize = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + view.measure(specSize, specSize) + width = view.measuredWidth + height = view.measuredHeight + } + + if (width <= 0 || height <= 0) { + return null + } + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + if (view.right <= 0 || view.bottom <= 0) { + view.layout(0, 0, width, height) + view.draw(canvas) + } else { + view.layout(view.left, view.top, view.right, view.bottom) + view.draw(canvas) + } + return bitmap + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java new file mode 100644 index 0000000..8d6adc0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java @@ -0,0 +1,385 @@ +package com.remax.visualnovel.utils; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.os.SystemClock; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import java.lang.reflect.Field; +import java.util.Timer; +import java.util.TimerTask; + +/** + * + */ +public final class KeyboardUtils { + + private static final int TAG_ON_GLOBAL_LAYOUT_LISTENER = -8; + + private KeyboardUtils() { + throw new UnsupportedOperationException("u can't instantiate me..."); + } + + /** + * Show the soft input. + */ + public static void showSoftInput() { + InputMethodManager imm = (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) { + return; + } + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + /** + * Show the soft input. + */ + public static void showSoftInput(@NonNull Activity activity) { + if (!isSoftInputVisible(activity)) { + toggleSoftInput(); + } + } + + /** + * Show the soft input. + * + * @param view The view. + */ +// public static void showSoftInput(@NonNull final View view) { +// showSoftInput(view, 0); +// } + + /** + * Show the soft input. + * + * @param view The view. + */ + public static void showSoftInput(@NonNull final View view) { + // 弹出软键盘,并且让EditText获取可输入的焦点 + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.requestFocus(); + // 在异步去执行键盘的展开 + Timer timer = new Timer(); + timer.schedule(new TimerTask() { + public void run() { + InputMethodManager inputManager = (InputMethodManager) view + .getContext().getSystemService( + Context.INPUT_METHOD_SERVICE); + if (null != inputManager) { + inputManager.showSoftInput(view, 0); + } + } + }, 300); + } + + + public static void showSoftInput(@NonNull final View view, final int flags) { + InputMethodManager imm = + (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.requestFocus(); + imm.showSoftInput(view, flags); +// imm.showSoftInput(view, flags, new ResultReceiver(new Handler()) { +// @Override +// protected void onReceiveResult(int resultCode, Bundle resultData) { +// if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN +// || resultCode == InputMethodManager.RESULT_HIDDEN) { +// toggleSoftInput(); +// } +// } +// }); +// imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + /** + * Hide the soft input. + * + * @param activity The activity. + */ + public static void hideSoftInput(@NonNull final Activity activity) { + hideSoftInput(activity.getWindow()); + } + + /** + * Hide the soft input. + * + * @param window The window. + */ + public static void hideSoftInput(@NonNull final Window window) { + View view = window.getCurrentFocus(); + if (view == null) { + View decorView = window.getDecorView(); + View focusView = decorView.findViewWithTag("keyboardTagView"); + if (focusView == null) { + view = new EditText(window.getContext()); + view.setTag("keyboardTagView"); + ((ViewGroup) decorView).addView(view, 0, 0); + } else { + view = focusView; + } + view.requestFocus(); + } + hideSoftInput(view); + } + + /** + * Hide the soft input. + * + * @param view The view. + */ + public static void hideSoftInput(@NonNull final View view) { + InputMethodManager imm = + (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + private static long millis; + + /** + * Hide the soft input. + * + * @param activity The activity. + */ + public static void hideSoftInputByToggle(final Activity activity) { + long nowMillis = SystemClock.uptimeMillis(); + long delta = nowMillis - millis; + if (Math.abs(delta) > 500 && KeyboardUtils.isSoftInputVisible(activity)) { + KeyboardUtils.toggleSoftInput(); + } + millis = nowMillis; + } + + /** + * Toggle the soft input display or not. + */ + public static void toggleSoftInput() { + if (CommonApplicationProxy.INSTANCE.getApplication() != null) { + InputMethodManager imm = + (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.toggleSoftInput(0, 0); + } + } + + private static int sDecorViewDelta = 0; + + /** + * Return whether soft input is visible. + * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isSoftInputVisible(@NonNull final Activity activity) { + return getDecorViewInvisibleHeight(activity.getWindow()) > 0; + } + + private static int getDecorViewInvisibleHeight(@NonNull final Window window) { + final View decorView = window.getDecorView(); + final Rect outRect = new Rect(); + decorView.getWindowVisibleDisplayFrame(outRect); + int delta = Math.abs(decorView.getBottom() - outRect.bottom); + if (delta <= StatusBarUtils.INSTANCE.getStatusBarHeight() + StatusBarUtils.INSTANCE.getNavBarHeight(false)) { + sDecorViewDelta = delta; + return 0; + } + return delta - sDecorViewDelta; + } + + /** + * Register soft input changed listener. + * + * @param activity The activity. + * @param listener The soft input changed listener. + */ + public static void registerSoftInputChangedListener(@NonNull final Activity activity, + @NonNull final OnSoftInputChangedListener listener) { + registerSoftInputChangedListener(activity.getWindow(), listener); + } + + /** + * Register soft input changed listener. + * + * @param window The window. + * @param listener The soft input changed listener. + */ + public static void registerSoftInputChangedListener(@NonNull final Window window, + @NonNull final OnSoftInputChangedListener listener) { + final int flags = window.getAttributes().flags; + if ((flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0) { + window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + } + final FrameLayout contentView = window.findViewById(android.R.id.content); + final int[] decorViewInvisibleHeightPre = {getDecorViewInvisibleHeight(window)}; + OnGlobalLayoutListener onGlobalLayoutListener = new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int height = getDecorViewInvisibleHeight(window); + if (decorViewInvisibleHeightPre[0] != height) { + listener.onSoftInputChanged(height); + decorViewInvisibleHeightPre[0] = height; + } + } + }; + contentView.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener); + contentView.setTag(TAG_ON_GLOBAL_LAYOUT_LISTENER, onGlobalLayoutListener); + } + + /** + * Unregister soft input changed listener. + * + * @param window The window. + */ + public static void unregisterSoftInputChangedListener(@NonNull final Window window) { + final View contentView = window.findViewById(android.R.id.content); + if (contentView == null) return; + Object tag = contentView.getTag(TAG_ON_GLOBAL_LAYOUT_LISTENER); + if (tag instanceof OnGlobalLayoutListener) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + contentView.getViewTreeObserver().removeOnGlobalLayoutListener((OnGlobalLayoutListener) tag); + } + } + } + + /** + * Fix the bug of 5497 in Android. + *

Don't set adjustResize

+ * + * @param activity The activity. + */ + public static void fixAndroidBug5497(@NonNull final Activity activity) { + fixAndroidBug5497(activity.getWindow()); + } + + /** + * Fix the bug of 5497 in Android. + *

It will clean the adjustResize

+ * + * @param window The window. + */ + public static void fixAndroidBug5497(@NonNull final Window window) { + int softInputMode = window.getAttributes().softInputMode; + window.setSoftInputMode(softInputMode & ~WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + final FrameLayout contentView = window.findViewById(android.R.id.content); + final View contentViewChild = contentView.getChildAt(0); + final int paddingBottom = contentViewChild.getPaddingBottom(); + final int[] contentViewInvisibleHeightPre5497 = {getContentViewInvisibleHeight(window)}; + contentView.getViewTreeObserver() + .addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int height = getContentViewInvisibleHeight(window); + if (contentViewInvisibleHeightPre5497[0] != height) { + contentViewChild.setPadding( + contentViewChild.getPaddingLeft(), + contentViewChild.getPaddingTop(), + contentViewChild.getPaddingRight(), + paddingBottom + getDecorViewInvisibleHeight(window) + ); + contentViewInvisibleHeightPre5497[0] = height; + } + } + }); + } + + private static int getContentViewInvisibleHeight(final Window window) { + final View contentView = window.findViewById(android.R.id.content); + if (contentView == null) return 0; + final Rect outRect = new Rect(); + contentView.getWindowVisibleDisplayFrame(outRect); + int delta = Math.abs(contentView.getBottom() - outRect.bottom); + if (delta <= StatusBarUtils.INSTANCE.getStatusBarHeight() + StatusBarUtils.INSTANCE.getNavBarHeight(false)) { + return 0; + } + return delta; + } + + /** + * Fix the leaks of soft input. + * + * @param activity The activity. + */ + public static void fixSoftInputLeaks(@NonNull final Activity activity) { + fixSoftInputLeaks(activity.getWindow()); + } + + /** + * Fix the leaks of soft input. + * + * @param window The window. + */ + public static void fixSoftInputLeaks(@NonNull final Window window) { + InputMethodManager imm = + (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + String[] leakViews = new String[]{"mLastSrvView", "mCurRootView", "mServedView", "mNextServedView"}; + for (String leakView : leakViews) { + try { + Field leakViewField = InputMethodManager.class.getDeclaredField(leakView); + if (!leakViewField.isAccessible()) { + leakViewField.setAccessible(true); + } + Object obj = leakViewField.get(imm); + if (!(obj instanceof View)) continue; + View view = (View) obj; + if (view.getRootView() == window.getDecorView().getRootView()) { + leakViewField.set(imm, null); + } + } catch (Throwable ignore) {/**/} + } + } + + /** + * Click blank area to hide soft input. + *

Copy the following code in ur activity.

+ */ + public static void clickBlankArea2HideSoftInput() { + /* + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + View v = getCurrentFocus(); + if (isShouldHideKeyboard(v, ev)) { + KeyboardUtils.hideSoftInput(this); + } + } + return super.dispatchTouchEvent(ev); + } + + // Return whether touch the view. + private boolean isShouldHideKeyboard(View v, MotionEvent event) { + if ((v instanceof EditText)) { + int[] l = {0, 0}; + v.getLocationOnScreen(l); + int left = l[0], + top = l[1], + bottom = top + v.getHeight(), + right = left + v.getWidth(); + return !(event.getRawX() > left && event.getRawX() < right + && event.getRawY() > top && event.getRawY() < bottom); + } + return false; + } + */ + } + + /////////////////////////////////////////////////////////////////////////// + // interface + /////////////////////////////////////////////////////////////////////////// + public interface OnSoftInputChangedListener { + void onSoftInputChanged(int height); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt new file mode 100644 index 0000000..a0b0dce --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.utils + +import timber.log.Timber + +/** + * Created by HJW on 2024/4/1 + */ +class NotLoggingTree : Timber.Tree(){ + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt new file mode 100644 index 0000000..c3ed97c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt @@ -0,0 +1,54 @@ +package com.remax.visualnovel.utils + +import com.alibaba.android.arouter.launcher.ARouter +import com.remax.visualnovel.BuildConfig + +/** + * 路由配置; + * Created by Eric on 2020/9/1 + */ +class Routers { + + companion object { + + private const val ROUTER = "/router/" + + /** + * 欢迎页 + */ + const val WELCOME = "${ROUTER}welcome" + const val TEST = "${ROUTER}test" + + /** + * main activity + */ + const val MAIN = "${ROUTER}main" + const val ROUTE_FRAG_BOOKLIST = "${ROUTER}bookList" + const val ROUTE_FRAG_MANGALIST = "${ROUTER}mangaList" + const val ROUTE_FRAG_ACTORLIST = "${ROUTER}actorList" + const val ROUTE_FRAG_HISTORY = "${ROUTER}history" + const val LOGIN = "${ROUTER}login" + const val REGISTER = "${ROUTER}register" + const val BROWSER = "${ROUTER}browser" + + + + fun navigation(path: String) { + ARouter.getInstance().build(path).navigation() + } + + fun navigationToUA() { + navigationToBrowser(url = BuildConfig.EPAL_TERMS_SERVICES) + } + + fun navigationToBrowser(title: String = "", url: String, needTitle: Boolean = true) { + ARouter.getInstance() + .build(BROWSER) + .withString("title", title) + .withBoolean("needTitle", needTitle) + .withString("url", url) + .navigation() + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java new file mode 100644 index 0000000..79ca166 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java @@ -0,0 +1,1476 @@ +package com.remax.visualnovel.utils; + +import static android.graphics.BlurMaskFilter.Blur; + +import android.annotation.SuppressLint; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BlurMaskFilter; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.text.Layout; +import android.text.Layout.Alignment; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.method.LinkMovementMethod; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.AlignmentSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.LeadingMarginSpan; +import android.text.style.LineHeightSpan; +import android.text.style.MaskFilterSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.ReplacementSpan; +import android.text.style.ScaleXSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.SubscriptSpan; +import android.text.style.SuperscriptSpan; +import android.text.style.TypefaceSpan; +import android.text.style.URLSpan; +import android.text.style.UnderlineSpan; +import android.text.style.UpdateAppearance; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.FloatRange; +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.drake.spannable.span.CenterImageSpan; + +import java.io.InputStream; +import java.io.Serializable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; + +import timber.log.Timber; + +/** + * 富文本类 + */ +public final class SpanUtils { + + private static final int COLOR_DEFAULT = 0xFEFFFFFF; + + public static final int ALIGN_BOTTOM = 0; + public static final int ALIGN_BASELINE = 1; + public static final int ALIGN_CENTER = 2; + public static final int ALIGN_TOP = 3; + + @IntDef({ALIGN_BOTTOM, ALIGN_BASELINE, ALIGN_CENTER, ALIGN_TOP}) + @Retention(RetentionPolicy.SOURCE) + public @interface Align { + } + + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + + public static SpanUtils with(final TextView textView) { + return new SpanUtils(textView); + } + + private TextView mTextView; + private CharSequence mText; + private int flag; + private int foregroundColor; + private int backgroundColor; + private int lineHeight; + private int alignLine; + private int quoteColor; + private int stripeWidth; + private int quoteGapWidth; + private int first; + private int rest; + private int bulletColor; + private int bulletRadius; + private int bulletGapWidth; + private int fontSize; + private boolean fontSizeIsDp; + private float proportion; + private float xProportion; + private boolean isStrikethrough; + private boolean isUnderline; + private boolean isSuperscript; + private boolean isSubscript; + private boolean isBold; + private boolean isItalic; + private boolean isBoldItalic; + private String fontFamily; + private Typeface typeface; + private Alignment alignment; + private int verticalAlign; + private ClickableSpan clickSpan; + private String url; + private float blurRadius; + private Blur style; + private Shader shader; + private float shadowRadius; + private float shadowDx; + private float shadowDy; + private int shadowColor; + private Object[] spans; + + private Bitmap imageBitmap; + private Drawable imageDrawable; + private Uri imageUri; + private int imageResourceId; + private int alignImage; + + private int spaceSize; + private int spaceColor; + + private SerializableSpannableStringBuilder mBuilder; + private boolean isCreated; + + private int mType; + private final int mTypeCharSequence = 0; + private final int mTypeImage = 1; + private final int mTypeSpace = 2; + + private SpanUtils(TextView textView) { + this(); + mTextView = textView; + } + + public SpanUtils() { + mBuilder = new SerializableSpannableStringBuilder(); + mText = ""; + mType = -1; + setDefault(); + } + + private void setDefault() { + flag = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; + foregroundColor = COLOR_DEFAULT; + backgroundColor = COLOR_DEFAULT; + lineHeight = -1; + quoteColor = COLOR_DEFAULT; + first = -1; + bulletColor = COLOR_DEFAULT; + fontSize = -1; + proportion = -1; + xProportion = -1; + isStrikethrough = false; + isUnderline = false; + isSuperscript = false; + isSubscript = false; + isBold = false; + isItalic = false; + isBoldItalic = false; + fontFamily = null; + typeface = null; + alignment = null; + verticalAlign = -1; + clickSpan = null; + url = null; + blurRadius = -1; + shader = null; + shadowRadius = -1; + spans = null; + + imageBitmap = null; + imageDrawable = null; + imageUri = null; + imageResourceId = -1; + + spaceSize = -1; + } + + /** + * Set the span of flag. + * + * @param flag The flag. + *
    + *
  • {@link Spanned#SPAN_INCLUSIVE_EXCLUSIVE}
  • + *
  • {@link Spanned#SPAN_INCLUSIVE_INCLUSIVE}
  • + *
  • {@link Spanned#SPAN_EXCLUSIVE_EXCLUSIVE}
  • + *
  • {@link Spanned#SPAN_EXCLUSIVE_INCLUSIVE}
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFlag(final int flag) { + this.flag = flag; + return this; + } + + /** + * Set the span of foreground's color. + * + * @param color The color of foreground + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setForegroundColor(@ColorInt final int color) { + this.foregroundColor = color; + return this; + } + + /** + * Set the span of background's color. + * + * @param color The color of background + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBackgroundColor(@ColorInt final int color) { + this.backgroundColor = color; + return this; + } + + /** + * Set the span of line height. + * + * @param lineHeight The line height, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setLineHeight(@IntRange(from = 0) final int lineHeight) { + return setLineHeight(lineHeight, ALIGN_CENTER); + } + + /** + * Set the span of line height. + * + * @param lineHeight The line height, in pixel. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER}
  • + *
  • {@link Align#ALIGN_BOTTOM}
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setLineHeight(@IntRange(from = 0) final int lineHeight, + @Align final int align) { + this.lineHeight = lineHeight; + this.alignLine = align; + return this; + } + + /** + * Set the span of quote's color. + * + * @param color The color of quote + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setQuoteColor(@ColorInt final int color) { + return setQuoteColor(color, 2, 2); + } + + /** + * Set the span of quote's color. + * + * @param color The color of quote. + * @param stripeWidth The width of stripe, in pixel. + * @param gapWidth The width of gap, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setQuoteColor(@ColorInt final int color, + @IntRange(from = 1) final int stripeWidth, + @IntRange(from = 0) final int gapWidth) { + this.quoteColor = color; + this.stripeWidth = stripeWidth; + this.quoteGapWidth = gapWidth; + return this; + } + + /** + * Set the span of leading margin. + * + * @param first The indent for the first line of the paragraph. + * @param rest The indent for the remaining lines of the paragraph. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setLeadingMargin(@IntRange(from = 0) final int first, + @IntRange(from = 0) final int rest) { + this.first = first; + this.rest = rest; + return this; + } + + /** + * Set the span of bullet. + * + * @param gapWidth The width of gap, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBullet(@IntRange(from = 0) final int gapWidth) { + return setBullet(0, 3, gapWidth); + } + + /** + * Set the span of bullet. + * + * @param color The color of bullet. + * @param radius The radius of bullet, in pixel. + * @param gapWidth The width of gap, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBullet(@ColorInt final int color, + @IntRange(from = 0) final int radius, + @IntRange(from = 0) final int gapWidth) { + this.bulletColor = color; + this.bulletRadius = radius; + this.bulletGapWidth = gapWidth; + return this; + } + + /** + * Set the span of font's size. + * + * @param size The size of font. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontSize(@IntRange(from = 0) final int size) { + return setFontSize(size, false); + } + + /** + * Set the span of size of font. + * + * @param size The size of font. + * @param isSp True to use sp, false to use pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontSize(@IntRange(from = 0) final int size, final boolean isSp) { + this.fontSize = size; + this.fontSizeIsDp = isSp; + return this; + } + + /** + * Set the span of proportion of font. + * + * @param proportion The proportion of font. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontProportion(final float proportion) { + this.proportion = proportion; + return this; + } + + /** + * Set the span of transverse proportion of font. + * + * @param proportion The transverse proportion of font. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontXProportion(final float proportion) { + this.xProportion = proportion; + return this; + } + + /** + * Set the span of strikethrough. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setStrikethrough() { + this.isStrikethrough = true; + return this; + } + + /** + * Set the span of underline. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setUnderline() { + this.isUnderline = true; + return this; + } + + /** + * Set the span of superscript. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setSuperscript() { + this.isSuperscript = true; + return this; + } + + /** + * Set the span of subscript. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setSubscript() { + this.isSubscript = true; + return this; + } + + /** + * Set the span of bold. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBold() { + isBold = true; + return this; + } + + /** + * Set the span of italic. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setItalic() { + isItalic = true; + return this; + } + + /** + * Set the span of bold italic. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBoldItalic() { + isBoldItalic = true; + return this; + } + + /** + * Set the span of font family. + * + * @param fontFamily The font family. + *
    + *
  • monospace
  • + *
  • serif
  • + *
  • sans-serif
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontFamily(@NonNull final String fontFamily) { + this.fontFamily = fontFamily; + return this; + } + + /** + * Set the span of typeface. + * + * @param typeface The typeface. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setTypeface(@NonNull final Typeface typeface) { + this.typeface = typeface; + return this; + } + + /** + * Set the span of horizontal alignment. + * + * @param alignment The alignment. + *
    + *
  • {@link Alignment#ALIGN_NORMAL }
  • + *
  • {@link Alignment#ALIGN_OPPOSITE}
  • + *
  • {@link Alignment#ALIGN_CENTER }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setHorizontalAlign(@NonNull final Alignment alignment) { + this.alignment = alignment; + return this; + } + + /** + * Set the span of vertical alignment. + * + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setVerticalAlign(@Align final int align) { + this.verticalAlign = align; + return this; + } + + /** + * Set the span of click. + *

Must set {@code view.setMovementMethod(LinkMovementMethod.getInstance())}

+ * + * @param clickSpan The span of click. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setClickSpan(@NonNull final ClickableSpan clickSpan) { + if (mTextView != null && mTextView.getMovementMethod() == null) { + mTextView.setMovementMethod(LinkMovementMethod.getInstance()); + } + this.clickSpan = clickSpan; + return this; + } + + /** + * Set the span of click. + *

Must set {@code view.setMovementMethod(LinkMovementMethod.getInstance())}

+ * + * @param color The color of click span. + * @param underlineText True to support underline, false otherwise. + * @param listener The listener of click span. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setClickSpan(@ColorInt final int color, + final boolean underlineText, + final View.OnClickListener listener) { + if (mTextView != null && mTextView.getMovementMethod() == null) { + mTextView.setHighlightColor(Color.TRANSPARENT); + mTextView.setMovementMethod(LinkMovementMethod.getInstance()); + } + this.clickSpan = new ClickableSpan() { + + @Override + public void updateDrawState(@NonNull TextPaint paint) { + paint.setColor(color); + paint.setUnderlineText(underlineText); + } + + @Override + public void onClick(@NonNull View widget) { + if (listener != null) { + listener.onClick(widget); + } + } + }; + return this; + } + + /** + * Set the span of url. + *

Must set {@code view.setMovementMethod(LinkMovementMethod.getInstance())}

+ * + * @param url The url. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setUrl(@NonNull final String url) { + if (mTextView != null && mTextView.getMovementMethod() == null) { + mTextView.setMovementMethod(LinkMovementMethod.getInstance()); + } + this.url = url; + return this; + } + + /** + * Set the span of blur. + * + * @param radius The radius of blur. + * @param style The style. + *
    + *
  • {@link Blur#NORMAL}
  • + *
  • {@link Blur#SOLID}
  • + *
  • {@link Blur#OUTER}
  • + *
  • {@link Blur#INNER}
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBlur(@FloatRange(from = 0, fromInclusive = false) final float radius, + final Blur style) { + this.blurRadius = radius; + this.style = style; + return this; + } + + /** + * Set the span of shader. + * + * @param shader The shader. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setShader(@NonNull final Shader shader) { + this.shader = shader; + return this; + } + + /** + * Set the span of shadow. + * + * @param radius The radius of shadow. + * @param dx X-axis offset, in pixel. + * @param dy Y-axis offset, in pixel. + * @param shadowColor The color of shadow. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setShadow(@FloatRange(from = 0, fromInclusive = false) final float radius, + final float dx, + final float dy, + final int shadowColor) { + this.shadowRadius = radius; + this.shadowDx = dx; + this.shadowDy = dy; + this.shadowColor = shadowColor; + return this; + } + + + /** + * Set the spans. + * + * @param spans The spans. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setSpans(@NonNull final Object... spans) { + if (spans.length > 0) { + this.spans = spans; + } + return this; + } + + /** + * Append the text text. + * + * @param text The text. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils append(@NonNull final CharSequence text) { + apply(mTypeCharSequence); + mText = text; + return this; + } + + /** + * Append one line. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendLine() { + apply(mTypeCharSequence); + mText = LINE_SEPARATOR; + return this; + } + + /** + * Append text and one line. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendLine(@NonNull final CharSequence text) { + apply(mTypeCharSequence); + mText = text + LINE_SEPARATOR; + return this; + } + + /** + * Append one image. + * + * @param bitmap The bitmap of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Bitmap bitmap) { + return appendImage(bitmap, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param bitmap The bitmap. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Bitmap bitmap, @Align final int align) { + apply(mTypeImage); + this.imageBitmap = bitmap; + this.alignImage = align; + return this; + } + + /** + * Append one image. + * + * @param drawable The drawable of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Drawable drawable) { + return appendImage(drawable, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param drawable The drawable of image. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Drawable drawable, @Align final int align) { + apply(mTypeImage); + this.imageDrawable = drawable; + this.alignImage = align; + return this; + } + + /** + * Append one image. + * + * @param uri The uri of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Uri uri) { + return appendImage(uri, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param uri The uri of image. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Uri uri, @Align final int align) { + apply(mTypeImage); + this.imageUri = uri; + this.alignImage = align; + return this; + } + + /** + * Append one image. + * + * @param resourceId The resource id of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@DrawableRes final int resourceId) { + return appendImage(resourceId, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param resourceId The resource id of image. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@DrawableRes final int resourceId, @Align final int align) { + apply(mTypeImage); + this.imageResourceId = resourceId; + this.alignImage = align; + return this; + } + + /** + * Append space. + * + * @param size The size of space. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendSpace(@IntRange(from = 0) final int size) { + return appendSpace(size, Color.TRANSPARENT); + } + + /** + * Append space. + * + * @param size The size of space. + * @param color The color of space. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendSpace(@IntRange(from = 0) final int size, @ColorInt final int color) { + apply(mTypeSpace); + spaceSize = size; + spaceColor = color; + return this; + } + + private void apply(final int type) { + applyLast(); + mType = type; + } + + public SpannableStringBuilder get() { + return mBuilder; + } + + /** + * Create the span string. + * + * @return the span string + */ + public SpannableStringBuilder create() { + applyLast(); + if (mTextView != null) { + mTextView.setText(mBuilder); + } + isCreated = true; + return mBuilder; + } + + private void applyLast() { + if (isCreated) { + return; + } + if (mType == mTypeCharSequence) { + updateCharCharSequence(); + } else if (mType == mTypeImage) { + updateImage(); + } else if (mType == mTypeSpace) { + updateSpace(); + } + setDefault(); + } + + private void updateCharCharSequence() { + if (mText.length() == 0) return; + int start = mBuilder.length(); + if (start == 0 && lineHeight != -1) {// bug of LineHeightSpan when first line + mBuilder.append(Character.toString((char) 2)) + .append("\n") + .setSpan(new AbsoluteSizeSpan(0), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + start = 2; + } + mBuilder.append(mText); + int end = mBuilder.length(); + if (verticalAlign != -1) { + mBuilder.setSpan(new VerticalAlignSpan(verticalAlign, foregroundColor), start, end, flag); + } + if (foregroundColor != COLOR_DEFAULT) { + mBuilder.setSpan(new ForegroundColorSpan(foregroundColor), start, end, flag); + } + if (backgroundColor != COLOR_DEFAULT) { + mBuilder.setSpan(new BackgroundColorSpan(backgroundColor), start, end, flag); + } + if (first != -1) { + mBuilder.setSpan(new LeadingMarginSpan.Standard(first, rest), start, end, flag); + } + if (quoteColor != COLOR_DEFAULT) { + mBuilder.setSpan( + new CustomQuoteSpan(quoteColor, stripeWidth, quoteGapWidth), + start, + end, + flag + ); + } + if (bulletColor != COLOR_DEFAULT) { + mBuilder.setSpan( + new CustomBulletSpan(bulletColor, bulletRadius, bulletGapWidth), + start, + end, + flag + ); + } + if (fontSize != -1) { + mBuilder.setSpan(new AbsoluteSizeSpan(fontSize, fontSizeIsDp), start, end, flag); + } + if (proportion != -1) { + mBuilder.setSpan(new RelativeSizeSpan(proportion), start, end, flag); + } + if (xProportion != -1) { + mBuilder.setSpan(new ScaleXSpan(xProportion), start, end, flag); + } + if (lineHeight != -1) { + mBuilder.setSpan(new CustomLineHeightSpan(lineHeight, alignLine), start, end, flag); + } + if (isStrikethrough) { + mBuilder.setSpan(new StrikethroughSpan(), start, end, flag); + } + if (isUnderline) { + mBuilder.setSpan(new UnderlineSpan(), start, end, flag); + } + if (isSuperscript) { + mBuilder.setSpan(new SuperscriptSpan(), start, end, flag); + } + if (isSubscript) { + mBuilder.setSpan(new SubscriptSpan(), start, end, flag); + } + if (isBold) { + mBuilder.setSpan(new StyleSpan(Typeface.BOLD), start, end, flag); + } + if (isItalic) { + mBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, end, flag); + } + if (isBoldItalic) { + mBuilder.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), start, end, flag); + } + if (fontFamily != null) { + mBuilder.setSpan(new TypefaceSpan(fontFamily), start, end, flag); + } + if (typeface != null) { + mBuilder.setSpan(new CustomTypefaceSpan(typeface), start, end, flag); + } + if (alignment != null) { + mBuilder.setSpan(new AlignmentSpan.Standard(alignment), start, end, flag); + } + if (clickSpan != null) { + mBuilder.setSpan(clickSpan, start, end, flag); + } + if (url != null) { + mBuilder.setSpan(new URLSpan(url), start, end, flag); + } + if (blurRadius != -1) { + mBuilder.setSpan( + new MaskFilterSpan(new BlurMaskFilter(blurRadius, style)), + start, + end, + flag + ); + } + if (shader != null) { + mBuilder.setSpan(new ShaderSpan(shader), start, end, flag); + } + if (shadowRadius != -1) { + mBuilder.setSpan( + new ShadowSpan(shadowRadius, shadowDx, shadowDy, shadowColor), + start, + end, + flag + ); + } + if (spans != null) { + for (Object span : spans) { + mBuilder.setSpan(span, start, end, flag); + } + } + } + + private void updateImage() { + int start = mBuilder.length(); + mText = ""; + updateCharCharSequence(); + int end = mBuilder.length(); + if (imageBitmap != null) { + if (alignImage == ALIGN_CENTER) { + mBuilder.setSpan(new CenterImageSpan(CommonApplicationProxy.INSTANCE.getApplication(), imageBitmap), start, end, flag); + } else { + mBuilder.setSpan(new CustomImageSpan(imageBitmap, alignImage), start, end, flag); + } + } else if (imageDrawable != null) { + if (alignImage == ALIGN_CENTER) { + mBuilder.setSpan(new CenterImageSpan(imageDrawable), start, end, flag); + } else { + mBuilder.setSpan(new CustomImageSpan(imageDrawable, alignImage), start, end, flag); + } + } else if (imageUri != null) { + mBuilder.setSpan(new CustomImageSpan(imageUri, alignImage), start, end, flag); + } else if (imageResourceId != -1) { + mBuilder.setSpan(new CustomImageSpan(imageResourceId, alignImage), start, end, flag); + } + } + + private void updateSpace() { + int start = mBuilder.length(); + mText = "< >"; + updateCharCharSequence(); + int end = mBuilder.length(); + mBuilder.setSpan(new SpaceSpan(spaceSize, spaceColor), start, end, flag); + } + + static class VerticalAlignSpan extends ReplacementSpan { + + static final int ALIGN_CENTER = 2; + static final int ALIGN_TOP = 3; + + private final int color; + + final int mVerticalAlignment; + + VerticalAlignSpan(int verticalAlignment, int color) { + this.color = color; + mVerticalAlignment = verticalAlignment; + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm) { + text = text.subSequence(start, end); + return (int) paint.measureText(text.toString()); + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + text = text.subSequence(start, end); + Paint.FontMetricsInt fm = paint.getFontMetricsInt(); +// int need = height - (v + fm.descent - fm.ascent - spanstartv); +// if (need > 0) { +// if (mVerticalAlignment == ALIGN_TOP) { +// fm.descent += need; +// } else if (mVerticalAlignment == ALIGN_CENTER) { +// fm.descent += need / 2; +// fm.ascent -= need / 2; +// } else { +// fm.ascent -= need; +// } +// } +// need = height - (v + fm.bottom - fm.top - spanstartv); +// if (need > 0) { +// if (mVerticalAlignment == ALIGN_TOP) { +// fm.bottom += need; +// } else if (mVerticalAlignment == ALIGN_CENTER) { +// fm.bottom += need / 2; +// fm.top -= need / 2; +// } else { +// fm.top -= need; +// } +// } + if (color != COLOR_DEFAULT) { + paint.setColor(color); + } + canvas.drawText(text.toString(), x, y - ((y + fm.descent + y + fm.ascent) / 2 - (bottom + top) / 2), paint); + } + } + + static class CustomLineHeightSpan implements LineHeightSpan { + + private final int height; + + static final int ALIGN_CENTER = 2; + static final int ALIGN_TOP = 3; + + final int mVerticalAlignment; + static Paint.FontMetricsInt sfm; + + CustomLineHeightSpan(int height, int verticalAlignment) { + this.height = height; + mVerticalAlignment = verticalAlignment; + } + + @Override + public void chooseHeight(final CharSequence text, final int start, final int end, + final int spanstartv, final int v, final Paint.FontMetricsInt fm) { +// LogUtils.e(fm, sfm); + if (sfm == null) { + sfm = new Paint.FontMetricsInt(); + sfm.top = fm.top; + sfm.ascent = fm.ascent; + sfm.descent = fm.descent; + sfm.bottom = fm.bottom; + sfm.leading = fm.leading; + } else { + fm.top = sfm.top; + fm.ascent = sfm.ascent; + fm.descent = sfm.descent; + fm.bottom = sfm.bottom; + fm.leading = sfm.leading; + } + int need = height - (v + fm.descent - fm.ascent - spanstartv); + if (need > 0) { + if (mVerticalAlignment == ALIGN_TOP) { + fm.descent += need; + } else if (mVerticalAlignment == ALIGN_CENTER) { + fm.descent += need / 2; + fm.ascent -= need / 2; + } else { + fm.ascent -= need; + } + } + need = height - (v + fm.bottom - fm.top - spanstartv); + if (need > 0) { + if (mVerticalAlignment == ALIGN_TOP) { + fm.bottom += need; + } else if (mVerticalAlignment == ALIGN_CENTER) { + fm.bottom += need / 2; + fm.top -= need / 2; + } else { + fm.top -= need; + } + } + if (end == ((Spanned) text).getSpanEnd(this)) { + sfm = null; + } +// LogUtils.e(fm, sfm); + } + } + + static class SpaceSpan extends ReplacementSpan { + + private final int width; + private final Paint paint = new Paint(); + + private SpaceSpan(final int width) { + this(width, Color.TRANSPARENT); + } + + private SpaceSpan(final int width, final int color) { + super(); + this.width = width; + paint.setColor(color); + paint.setStyle(Paint.Style.FILL); + } + + @Override + public int getSize(@NonNull final Paint paint, final CharSequence text, + @IntRange(from = 0) final int start, + @IntRange(from = 0) final int end, + @Nullable final Paint.FontMetricsInt fm) { + return width; + } + + @Override + public void draw(@NonNull final Canvas canvas, final CharSequence text, + @IntRange(from = 0) final int start, + @IntRange(from = 0) final int end, + final float x, final int top, final int y, final int bottom, + @NonNull final Paint paint) { + canvas.drawRect(x, top, x + width, bottom, this.paint); + } + } + + static class CustomQuoteSpan implements LeadingMarginSpan { + + private final int color; + private final int stripeWidth; + private final int gapWidth; + + private CustomQuoteSpan(final int color, final int stripeWidth, final int gapWidth) { + super(); + this.color = color; + this.stripeWidth = stripeWidth; + this.gapWidth = gapWidth; + } + + public int getLeadingMargin(final boolean first) { + return stripeWidth + gapWidth; + } + + public void drawLeadingMargin(final Canvas c, final Paint p, final int x, final int dir, + final int top, final int baseline, final int bottom, + final CharSequence text, final int start, final int end, + final boolean first, final Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + + p.setStyle(Paint.Style.FILL); + p.setColor(this.color); + + c.drawRect(x, top, x + dir * stripeWidth, bottom, p); + + p.setStyle(style); + p.setColor(color); + } + } + + static class CustomBulletSpan implements LeadingMarginSpan { + + private final int color; + private final int radius; + private final int gapWidth; + + private Path sBulletPath = null; + + private CustomBulletSpan(final int color, final int radius, final int gapWidth) { + this.color = color; + this.radius = radius; + this.gapWidth = gapWidth; + } + + public int getLeadingMargin(final boolean first) { + return 2 * radius + gapWidth; + } + + public void drawLeadingMargin(final Canvas c, final Paint p, final int x, final int dir, + final int top, final int baseline, final int bottom, + final CharSequence text, final int start, final int end, + final boolean first, final Layout l) { + if (((Spanned) text).getSpanStart(this) == start) { + Paint.Style style = p.getStyle(); + int oldColor = 0; + oldColor = p.getColor(); + p.setColor(color); + p.setStyle(Paint.Style.FILL); + if (c.isHardwareAccelerated()) { + if (sBulletPath == null) { + sBulletPath = new Path(); + // Bullet is slightly better to avoid aliasing artifacts on mdpi devices. + sBulletPath.addCircle(0.0f, 0.0f, radius, Path.Direction.CW); + } + c.save(); + c.translate(x + dir * radius, (top + bottom) / 2.0f); + c.drawPath(sBulletPath, p); + c.restore(); + } else { + c.drawCircle(x + dir * radius, (top + bottom) / 2.0f, radius, p); + } + p.setColor(oldColor); + p.setStyle(style); + } + } + } + + @SuppressLint("ParcelCreator") + static class CustomTypefaceSpan extends TypefaceSpan { + + private final Typeface newType; + + private CustomTypefaceSpan(final Typeface type) { + super(""); + newType = type; + } + + @Override + public void updateDrawState(final TextPaint textPaint) { + apply(textPaint, newType); + } + + @Override + public void updateMeasureState(final TextPaint paint) { + apply(paint, newType); + } + + private void apply(final Paint paint, final Typeface tf) { + int oldStyle; + Typeface old = paint.getTypeface(); + if (old == null) { + oldStyle = 0; + } else { + oldStyle = old.getStyle(); + } + + int fake = oldStyle & ~tf.getStyle(); + if ((fake & Typeface.BOLD) != 0) { + paint.setFakeBoldText(true); + } + + if ((fake & Typeface.ITALIC) != 0) { + paint.setTextSkewX(-0.25f); + } + + paint.getShader(); + + paint.setTypeface(tf); + } + } + + static class CustomImageSpan extends CustomDynamicDrawableSpan { + private Drawable mDrawable; + private Uri mContentUri; + private int mResourceId; + + private CustomImageSpan(final Bitmap b, final int verticalAlignment) { + super(verticalAlignment); + mDrawable = new BitmapDrawable(CommonApplicationProxy.INSTANCE.getApplication().getResources(), b); + mDrawable.setBounds( + 0, 0, mDrawable.getIntrinsicWidth(), mDrawable.getIntrinsicHeight() + ); + } + + private CustomImageSpan(final Drawable d, final int verticalAlignment) { + super(verticalAlignment); + mDrawable = d; + mDrawable.setBounds( + 0, 0, mDrawable.getIntrinsicWidth(), mDrawable.getIntrinsicHeight() + ); + } + + private CustomImageSpan(final Uri uri, final int verticalAlignment) { + super(verticalAlignment); + mContentUri = uri; + } + + private CustomImageSpan(@DrawableRes final int resourceId, final int verticalAlignment) { + super(verticalAlignment); + mResourceId = resourceId; + } + + @Override + public Drawable getDrawable() { + Drawable drawable = null; + if (mDrawable != null) { + drawable = mDrawable; + } else if (mContentUri != null) { + Bitmap bitmap; + try { + InputStream is = + CommonApplicationProxy.INSTANCE.getApplication().getContentResolver().openInputStream(mContentUri); + bitmap = BitmapFactory.decodeStream(is); + drawable = new BitmapDrawable(CommonApplicationProxy.INSTANCE.getApplication().getResources(), bitmap); + drawable.setBounds( + 0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight() + ); + if (is != null) { + is.close(); + } + } catch (Exception e) { + Timber.e(e, "sms Failed to loaded content" + mContentUri); + } + } else { + try { + drawable = ContextCompat.getDrawable(CommonApplicationProxy.INSTANCE.getApplication(), mResourceId); + if (drawable!=null){ + drawable.setBounds( + 0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight() + ); + } + } catch (Exception e) { + Timber.e(e, "sms Unable to find resource:" + mResourceId); + } + } + return drawable; + } + } + + static abstract class CustomDynamicDrawableSpan extends ReplacementSpan { + + static final int ALIGN_BOTTOM = 0; + + static final int ALIGN_BASELINE = 1; + + static final int ALIGN_CENTER = 2; + + static final int ALIGN_TOP = 3; + + final int mVerticalAlignment; + + private CustomDynamicDrawableSpan() { + mVerticalAlignment = ALIGN_BOTTOM; + } + + private CustomDynamicDrawableSpan(final int verticalAlignment) { + mVerticalAlignment = verticalAlignment; + } + + public abstract Drawable getDrawable(); + + @Override + public int getSize(@NonNull final Paint paint, final CharSequence text, + final int start, final int end, final Paint.FontMetricsInt fm) { + Drawable d = getCachedDrawable(); + Rect rect = d.getBounds(); + if (fm != null) { +// LogUtils.d("fm.top: " + fm.top, +// "fm.ascent: " + fm.ascent, +// "fm.descent: " + fm.descent, +// "fm.bottom: " + fm.bottom, +// "lineHeight: " + (fm.bottom - fm.top)); + int lineHeight = fm.bottom - fm.top; + if (lineHeight < rect.height()) { + if (mVerticalAlignment == ALIGN_TOP) { + fm.top = fm.top; + fm.bottom = rect.height() + fm.top; + } else if (mVerticalAlignment == ALIGN_CENTER) { + fm.top = -rect.height() / 2 - lineHeight / 4; + fm.bottom = rect.height() / 2 - lineHeight / 4; + } else { + fm.top = -rect.height() + fm.bottom; + fm.bottom = fm.bottom; + } + fm.ascent = fm.top; + fm.descent = fm.bottom; + } + } + return rect.right; + } + + @Override + public void draw(@NonNull final Canvas canvas, final CharSequence text, + final int start, final int end, final float x, + final int top, final int y, final int bottom, @NonNull final Paint paint) { + Drawable d = getCachedDrawable(); + Rect rect = d.getBounds(); + canvas.save(); + float transY; + int lineHeight = bottom - top; +// LogUtils.d("rectHeight: " + rect.height(), +// "lineHeight: " + (bottom - top)); + if (rect.height() < lineHeight) { + if (mVerticalAlignment == ALIGN_TOP) { + transY = top; + } else if (mVerticalAlignment == ALIGN_CENTER) { + transY = (bottom + top - rect.height()) / 2; + } else if (mVerticalAlignment == ALIGN_BASELINE) { + transY = y - rect.height(); + } else { + transY = bottom - rect.height(); + } + canvas.translate(x, transY); + } else { + canvas.translate(x, top); + } + d.draw(canvas); + canvas.restore(); + } + + private Drawable getCachedDrawable() { + WeakReference wr = mDrawableRef; + Drawable d = null; + if (wr != null) { + d = wr.get(); + } + if (d == null) { + d = getDrawable(); + mDrawableRef = new WeakReference<>(d); + } + return d; + } + + private WeakReference mDrawableRef; + } + + static class ShaderSpan extends CharacterStyle implements UpdateAppearance { + private Shader mShader; + + private ShaderSpan(final Shader shader) { + this.mShader = shader; + } + + @Override + public void updateDrawState(final TextPaint tp) { + tp.setShader(mShader); + } + } + + static class ShadowSpan extends CharacterStyle implements UpdateAppearance { + private float radius; + private float dx, dy; + private int shadowColor; + + private ShadowSpan(final float radius, + final float dx, + final float dy, + final int shadowColor) { + this.radius = radius; + this.dx = dx; + this.dy = dy; + this.shadowColor = shadowColor; + } + + @Override + public void updateDrawState(final TextPaint tp) { + tp.setShadowLayer(radius, dx, dy, shadowColor); + } + } + + private static class SerializableSpannableStringBuilder extends SpannableStringBuilder + implements Serializable { + + private static final long serialVersionUID = 4909567650765875771L; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt new file mode 100644 index 0000000..e558511 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt @@ -0,0 +1,194 @@ +package com.remax.visualnovel.utils + + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnAttach +import com.remax.visualnovel.R +import com.remax.visualnovel.configs.NovelApplication +import com.remax.visualnovel.constant.AppConstant +import com.remax.visualnovel.extension.findActivityContext +import timber.log.Timber +import kotlin.math.abs + + +object StatusBarUtils { + private val FAKE_STATUS_BAR_VIEW_ID = R.id.fake_status_bar_view + + /** + * 前景亮色/暗色 + * @param activity Activity + * @param isLight Boolean true:改为黑色 + */ + fun setStatusBarAndNavBarIsLight(activity: Activity, isLight: Boolean) { + WindowCompat.getInsetsController(activity.window, activity.window.decorView).isAppearanceLightStatusBars = isLight + WindowCompat.getInsetsController(activity.window, activity.window.decorView).isAppearanceLightNavigationBars = isLight + } + + var statusBarHeight = 0 + private var navBarHeight = 0 + + /** + * 获得状态栏的高度 + * Insets 只有在 view attached 才是可用的 + */ + @SuppressLint("InternalInsetResource", "DiscouragedApi") + fun getStatusBarHeight(activity: Activity) { + activity.window.decorView.doOnAttach { + /** + * 正确获取status bar方法,通过windowInset获取 + * getInsetsIgnoringVisibility 是获取到真实高度,无论状态栏是否隐藏 + * getInsets 是根据隐藏状态获取 + */ + val ignoringVisibilityTop = + ViewCompat.getRootWindowInsets(activity.window.decorView)?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.top ?: 0 + val ignoringVisibilityBottom = + ViewCompat.getRootWindowInsets(activity.window.decorView)?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.bottom ?: 0 + statusBarHeight = abs(ignoringVisibilityTop - ignoringVisibilityBottom) + /** + * 兼容,系统resources可能修改 + */ + if (statusBarHeight == 0) { + statusBarHeight = try { + activity.resources + .getIdentifier("status_bar_height", "dimen", AppConstant.ANDROID) + .takeIf { it > 0 } + ?.run { + activity.resources.getDimensionPixelSize(this) + } ?: 0 + } catch (_: Exception) { + 0 + } + } + } + } + + fun resetNavBarHeight(){ + navBarHeight = 0 + } + + /** + * 获得下方导航栏的高度 + * 获取的是可见高度,注释见上 + */ + fun getNavBarHeight(ignoringVisibility: Boolean = false): Int { + return if (navBarHeight == 0) { + NovelApplication.getCurrentActivity()?.run { + val rootWindowInsets = ViewCompat.getRootWindowInsets(window.decorView) + val typeMask = WindowInsetsCompat.Type.navigationBars() + val insets = if (ignoringVisibility) rootWindowInsets?.getInsetsIgnoringVisibility(typeMask) else rootWindowInsets?.getInsets(typeMask) + val top = insets?.top ?: 0 + val bottom = insets?.bottom ?: 0 + navBarHeight = abs(bottom - top) + Timber.d("获得下方导航栏的高度 $navBarHeight") + navBarHeight + } ?: 0 + } else { + navBarHeight + } + +// /** +// * 野路子,暂时兼容一下,系统resources可能修改 +// */ +// if (navBarHeight == 0) { +// val res: Resources = Resources.getSystem() +// val resourceId: Int = res.getIdentifier("navigation_bar_height", "dimen", AppConstant.ANDROID) +// resourceId.takeIf { it != 0 }?.run { +// navBarHeight = res.getDimensionPixelSize(this) +// } +// } + } + + /** + * 设置状态栏颜色 + * @param context 上下文,尽量使用Activity + * @param color 状态栏颜色 + */ + fun setColor(context: Context, @ColorInt color: Int) { + (context.findActivityContext() as? Activity)?.run { + window.apply { + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + statusBarColor = color + } + setStatusBarAndNavBarIsLight(this, !isDarkColor(color)) + } + } + + /** + * Android 5.0 以下版本设置状态栏颜色 + * + * @param window 窗口 + * @param color 状态栏颜色值 + * @param isTransparent 是否透明 + */ + fun setColor(window: Window, @ColorInt color: Int, isTransparent: Boolean) { + val context: Context = window.context + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + val decorView: ViewGroup = window.decorView as ViewGroup + val contentView = + decorView.findViewById(android.R.id.content) + val top = contentView?.paddingTop + Timber.d(top.toString()) + contentView?.setPadding(0, if (isTransparent) 0 else statusBarHeight, 0, 0) + val fakeStatusBarView = + decorView.findViewById(FAKE_STATUS_BAR_VIEW_ID) + if (fakeStatusBarView != null) { + fakeStatusBarView.setBackgroundColor(color) + if (fakeStatusBarView.visibility == View.GONE) { + fakeStatusBarView.visibility = View.VISIBLE + } + } else { + // 绘制一个和状态栏一样高的矩形 + val statusBarView = View(context) + val layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + statusBarHeight + ) + statusBarView.layoutParams = layoutParams + statusBarView.setBackgroundColor(color) + statusBarView.id = FAKE_STATUS_BAR_VIEW_ID + decorView.addView(statusBarView) + } + } + + /** + * 设置状态栏透明 + * + * @param context 上下文,尽量使用Activity + */ + fun setTransparent(context: Context) { + (context.findActivityContext() as? Activity)?.run { + this.window.apply { + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + statusBarColor = Color.TRANSPARENT + } + setStatusBarAndNavBarIsLight(this, false) + } + } + + /** + * 判断颜色是否为深色 + * + * @param color 要判断的颜色 + * @return 是否为深色 + */ + fun isDarkColor(@ColorInt color: Int) = ColorUtils.calculateLuminance(color) < 0.5 + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt new file mode 100644 index 0000000..306560b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt @@ -0,0 +1,227 @@ +package com.remax.visualnovel.utils + +import android.content.Context +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * Created by HJW on 2025/7/11 + */ +object TimeUtils { + + private val context by lazy { + CommonApplicationProxy.application + } + + const val ONE_SECOND: Long = 1000 + + + const val ONE_MINUTE: Long = (60 * 1000).toLong() + + + const val ONE_HOUR: Long = 60 * ONE_MINUTE + + + const val ONE_DAY: Long = 24 * ONE_HOUR + + + const val ONE_WEEK: Long = 7 * ONE_DAY + + + const val ONE_MONTH: Long = 30 * ONE_DAY + + const val ONE_YEAR: Long = 365 * ONE_DAY + + const val YM_PATTERN: String = "yyyy-MM" + const val YMD_PATTERN: String = "yyyy-MM-dd" + const val YMD_PATTERN_2: String = "yyyyMMdd" + const val YMDHM_PATTERN: String = "yyyy-MM-dd HH:mm" + + fun getBirthDateString(context: Context, datetime: String, needYear: Boolean): String { + try { + val formatter = SimpleDateFormat(YMD_PATTERN, Locale.getDefault()) + val date = formatter.parse(datetime) + val timeMillis = date?.time ?: 0 + + val formatter2 = if (TimeUtils.isThisYear(timeMillis) && !needYear) + SimpleDateFormat(context.getString(R.string.time_format_m_d), Locale.getDefault()) + else + SimpleDateFormat(context.getString(R.string.time_format_y_m_d), Locale.getDefault()) + + return if (date != null) formatter2.format(date) else "" + } catch (e: ParseException) { + return datetime + } + } + + fun getBirthDate(datetime: String): Date { + try { + val formatter = SimpleDateFormat(YMD_PATTERN, Locale.getDefault()) + val date = formatter.parse(datetime) + return date ?: Date() + } catch (e: ParseException) { + return Date() + } + } + + /** + * 消息时间: + * 今天 hh:mm AM/PM + * 昨天 YDA hh:mm AM/PM + * 之前 MM-dd hh:mm AM/PM + */ + fun formatMsgTime(time: Long): String? { + var txtPreRes = 0 + val patternRes = when { + System.currentTimeMillis() - time < ONE_MINUTE -> { + txtPreRes = R.string.time_just_now + 0 + } + + //不在一年以内 + !isThisYear(time) -> { + R.string.time_format_y_m_d_h_m + } + + isToday(time) -> { + R.string.time_format_h_m + } + + isYesterday(time) -> { + txtPreRes = R.string.yesterday + R.string.time_format_h_m + } + + else -> { + R.string.time_format_m_d_h_m + } + } + + val txtPre = if (txtPreRes != 0) "${context.getString(txtPreRes)} " else "" + val txtTime = if (patternRes != 0) { + SimpleDateFormat(context.getString(patternRes), Locale.getDefault()).format(Date(time)) + } else "" + + return txtPre + txtTime + } + + /** + * 是否是昨天 + */ + fun isYesterday(time: Long): Boolean { + val pre = Calendar.getInstance() + val predate = Date(System.currentTimeMillis()) + pre.setTime(predate) + + val cal = Calendar.getInstance() + val date = Date(time) + cal.setTime(date) + + if (cal.get(Calendar.YEAR) == (pre.get(Calendar.YEAR))) { + val diffDay = (cal.get(Calendar.DAY_OF_YEAR) + - pre.get(Calendar.DAY_OF_YEAR)) + return diffDay == -1 + } + return false + } + + /** + * 是否是同一天 + */ + fun isSameDay(firstTime: Long, secondTime: Long): Boolean { + val pre = Calendar.getInstance() + val predate = Date(firstTime) + pre.setTime(predate) + + val cal = Calendar.getInstance() + val date = Date(secondTime) + cal.setTime(date) + + if (cal.get(Calendar.YEAR) == (pre.get(Calendar.YEAR))) { + val diffDay = (cal.get(Calendar.DAY_OF_YEAR) + - pre.get(Calendar.DAY_OF_YEAR)) + + return diffDay == 0 + } + return false + } + + /** + * 是否是今天 + */ + fun isToday(time: Long): Boolean { + val pre = Calendar.getInstance() + val predate = Date(System.currentTimeMillis()) + pre.setTime(predate) + + val cal = Calendar.getInstance() + val date = Date(time) + cal.setTime(date) + + if (cal.get(Calendar.YEAR) == (pre.get(Calendar.YEAR))) { + val diffDay = (cal.get(Calendar.DAY_OF_YEAR) + - pre.get(Calendar.DAY_OF_YEAR)) + + return diffDay == 0 + } + return false + } + + /** + * 当前时间加1年 + * 是否是今年 + */ + fun isThisYear(time: Long): Boolean { + val date = Date() + val sdf = SimpleDateFormat(YMDHM_PATTERN, Locale.getDefault()) + val now = sdf.format(date) + val handleTime = sdf.format(Date(time)) + return now.substring(0, 4) == handleTime.substring(0, 4) + } + + fun format_y_m_d_h_m(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m_d_h_m), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_m_d_h_m_s(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_m_d_h_m_s), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_h_m_a(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_h_m), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y_m_d_h_m_s(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m_d_h_m_s), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y_m_d(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m_d), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y_m(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_m_d(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_m_d), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y), Locale.getDefault()) + return format.format(Date(date)) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt new file mode 100644 index 0000000..0f63d65 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023. Dylan Cai + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +package com.remax.visualnovel.utils.datastore + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +open class DataStoreOwner(name: String) : IDataStoreOwner { + private val Context.dataStore by preferencesDataStore(name) + override val dataStore get() = context.dataStore +} + +interface IDataStoreOwner { + val context: Context get() = application + + val dataStore: DataStore + + fun intPreference(default: Int? = null): ReadOnlyProperty> = + PreferenceProperty(::intPreferencesKey, default) + + fun doublePreference(default: Double? = null): ReadOnlyProperty> = + PreferenceProperty(::doublePreferencesKey, default) + + fun longPreference(default: Long? = null): ReadOnlyProperty> = + PreferenceProperty(::longPreferencesKey, default) + + fun floatPreference(default: Float? = null): ReadOnlyProperty> = + PreferenceProperty(::floatPreferencesKey, default) + + fun booleanPreference(default: Boolean? = null): ReadOnlyProperty> = + PreferenceProperty(::booleanPreferencesKey, default) + + fun stringPreference(default: String? = null): ReadOnlyProperty> = + PreferenceProperty(::stringPreferencesKey, default) + + fun stringSetPreference(default: Set? = null): ReadOnlyProperty>> = + PreferenceProperty(::stringSetPreferencesKey, default) + + class PreferenceProperty( + private val key: (String) -> Preferences.Key, + private val default: V? = null, + ) : ReadOnlyProperty> { + private var cache: DataStorePreference? = null + + override fun getValue(thisRef: IDataStoreOwner, property: KProperty<*>): DataStorePreference = + cache ?: DataStorePreference(thisRef.dataStore, key(property.name), default).also { cache = it } + } + + companion object { + internal lateinit var application: Application + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt new file mode 100644 index 0000000..67933be --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023. Dylan Cai + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused", "MemberVisibilityCanBePrivate") + +package com.remax.visualnovel.utils.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +operator fun Preferences.get(preference: DataStorePreference) = this[preference.key] + +open class DataStorePreference( + private val dataStore: DataStore, + val key: Preferences.Key, + open val default: V? +) { + + suspend fun set(block: suspend V?.(Preferences) -> V?): Preferences = + dataStore.edit { preferences -> + val value = block(preferences[key] ?: default, preferences) + if (value == null) { + preferences.remove(key) + } else { + preferences[key] = value + } + } + + suspend fun set(value: V?): Preferences = set { value } + + fun asFlow(): Flow = + dataStore.data.map { it[key] ?: default } + + fun asLiveData(): LiveData = asFlow().asLiveData() + + suspend fun get(): V? = asFlow().first() + + suspend fun getOrDefault(): V = get() ?: throw IllegalStateException("No default value") +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt new file mode 100644 index 0000000..dfcce91 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt @@ -0,0 +1,100 @@ +package com.remax.visualnovel.utils.mmkv + +import android.os.Parcelable +import androidx.lifecycle.MutableLiveData +import com.tencent.mmkv.MMKV +import kotlin.properties.ReadOnlyProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * A class that has a MMKV instance. If you want to customize the MMKV, you can override + * the kv property. For example: + * + * ```kotlin + * object DataRepository : MMKVOwner { + * override val kv = MMKV.mmkvWithID("MyID") + * } + * ``` + */ +interface IMMKVOwner { + val mmapID: String + val kv: MMKV +} + +open class MMKVOwner(override val mmapID: String) : IMMKVOwner { + override val kv: MMKV by lazy { MMKV.mmkvWithID(mmapID) } +} + +fun IMMKVOwner.mmkvInt(default: Int = 0) = + MMKVProperty({ kv.decodeInt(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvLong(default: Long = 0L) = + MMKVProperty({ kv.decodeLong(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvBool(default: Boolean = false) = + MMKVProperty({ kv.decodeBool(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvFloat(default: Float = 0f) = + MMKVProperty({ kv.decodeFloat(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvDouble(default: Double = 0.0) = + MMKVProperty({ kv.decodeDouble(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvString() = + MMKVProperty({ kv.decodeString(it) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvString(default: String) = + MMKVProperty({ kv.decodeString(it) ?: default }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvStringSet() = + MMKVProperty({ kv.decodeStringSet(it) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvStringSet(default: Set) = + MMKVProperty({ kv.decodeStringSet(it) ?: default }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvBytes() = + MMKVProperty({ kv.decodeBytes(it) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvBytes(default: ByteArray) = + MMKVProperty({ kv.decodeBytes(it) ?: default }, { kv.encode(first, second) }) + +inline fun IMMKVOwner.mmkvParcelable() = + MMKVProperty({ kv.decodeParcelable(it, T::class.java) }, { kv.encode(first, second) }) + +inline fun IMMKVOwner.mmkvParcelable(default: T) = + MMKVProperty({ kv.decodeParcelable(it, T::class.java) ?: default }, { kv.encode(first, second) }) + +fun MMKVProperty.asLiveData() = object : ReadOnlyProperty> { + private var cache: MutableLiveData? = null + + override fun getValue(thisRef: IMMKVOwner, property: KProperty<*>): MutableLiveData = + cache ?: object : MutableLiveData() { + override fun getValue() = this@asLiveData.getValue(thisRef, property) + + override fun setValue(value: V) { + if (super.getValue() == value) return + this@asLiveData.setValue(thisRef, property, value) + super.setValue(value) + } + + override fun onActive() = super.setValue(value) + }.also { cache = it } +} + + +class MMKVProperty( + private val decode: (String) -> V, + private val encode: Pair.() -> Boolean +) : ReadWriteProperty { + private var cache: V? = null + + override fun getValue(thisRef: IMMKVOwner, property: KProperty<*>): V = + cache ?: decode(property.name).also { cache = it } + + override fun setValue(thisRef: IMMKVOwner, property: KProperty<*>, value: V) { + if (encode(property.name to value)) { + cache = value + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt new file mode 100644 index 0000000..878c7d1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex + +import androidx.annotation.Keep +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanReplacementMatch + +/** + * 替换规则 + */ +@Keep +data class ReplaceRule( + /** + * 查找的字符串或正则文本 + */ + val replaceString: String, + /** + * [replaceString]是否为正则 + */ + val isRegex: Boolean, + /** + * 匹配范围 + */ + val matchRange: IntRange?, + /** + * 替换文本(null 为不替换) + */ + val newString: CharSequence?, + /** + * 匹配时回调 + */ + val replacementMatch: OnSpanReplacementMatch? +) { + internal val replaceRules: Regex + get() = (if (isRegex) replaceString else Regex.escape(replaceString)).toRegex() +} + + +/** + * 创建替换规则 + * @receiver 查找的字符串或正则文本 + * @param isRegex receiver是否为正则 + * @param matchIndex 单一匹配位置 ([matchRange]不为null时优先使用[matchRange]) + * @param matchRange 匹配范围 + * @param newString 替换文本(null 为不替换) + * @param replacementMatch 匹配时回调 + */ +fun String.toReplaceRule( + isRegex: Boolean = false, + matchIndex: Int? = null, + matchRange: IntRange? = null, + newString: CharSequence? = null, + replacementMatch: OnSpanReplacementMatch? = null +): ReplaceRule = ReplaceRule( + replaceString = this, + isRegex = isRegex, + matchRange = matchRange ?: matchIndex?.let { matchIndex..matchIndex }, + newString = newString, + replacementMatch = replacementMatch +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt new file mode 100644 index 0000000..888ba5f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt @@ -0,0 +1,932 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("Span") +@file:Suppress("unused") + +package com.remax.visualnovel.utils.spannablex + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.* +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.* +import android.text.method.LinkMovementMethod +import android.text.style.* +import android.widget.TextView +import androidx.annotation.* +import androidx.annotation.IntRange +import androidx.core.text.buildSpannedString +import com.bumptech.glide.request.RequestOptions +import com.drake.spannable.movement.ClickableMovementMethod +import com.drake.spannable.span.CenterImageSpan +import com.drake.spannable.span.GlideImageSpan +import com.drake.spannable.span.MarginSpan +import com.remax.visualnovel.utils.spannablex.annotation.ConversionUnit +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanReplacementMatch +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize +import com.remax.visualnovel.utils.spannablex.utils.drawableSize +import com.remax.visualnovel.utils.spannablex.utils.color +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.utils.spannablex.utils.sp +import java.util.* + +/** + *

Chain Spannable

+ * sample: + *
+ *
TextView.setText(Span.create() + *
.text("this is real text.") + *
.text("spannable").color(Color.BLUE).style(Typeface.BOLD) + *
.spannable()) + */ +class Span private constructor() { + + private val spannableBuilder = SpannableStringBuilder() + private var spannableCache: Spannable? = null + + private val Spannable?.isNotNullAndEmpty: Boolean + get() = this != null && this.isNotEmpty() + + private fun runOnSelf(block: () -> Spannable?): Span = apply { + block.invoke()?.let { spannableCache = it } + } + + private fun checkImageSpan(autoPlaceholder: Boolean = false) { + if (autoPlaceholder) { + saveCache() + spannableCache = SpannableString(" ") + } + } + + /** + * 保存当前 [text] spannable(大部分情况不需要手动调用) + */ + fun saveCache(): Span = apply { + if (spannableCache.isNotNullAndEmpty) { + spannableBuilder.append(spannableCache) + } + } + + /** + * 插入待处理字符串 + * 在使用[style] [typeface] [color]... 等等之前,需调用该方法插入当前需要处理的字符串 + */ + fun text(text: CharSequence): Span = apply { + saveCache() + spannableCache = if (text is Spannable) { + text + } else SpannableString(text) + } + + /** + * 换行(可自行处理`\n`) + */ + @JvmOverloads + fun newline(@IntRange(from = 1L) lines: Int = 1): Span = apply { + val newlines = if (lines > 1) { + buildString { + repeat(lines) { append("\n") } + } + } else "\n" + when (val cache = spannableCache) { + is SpannableStringBuilder -> cache.append(newlines) + is Spanned -> spannableCache = SpannableStringBuilder(cache).append(newlines) + is CharSequence -> spannableCache = SpannableString(cache + newlines) + else -> spannableBuilder.append(newlines) + } + } + + /** + * 构建Spannable + */ + fun spannable(): CharSequence { + saveCache() + spannableCache = null + return SpannedString(spannableBuilder) + } + + /** + * [StyleSpan] 设置文本样式 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun style( + @TextStyle style: Int, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanStyle(style, replaceRule) } + + /** + * [TypefaceSpan] 设置字体样式 + * + * @param typeface 字体(API>=28) + * @param family 字体集 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun typeface( + typeface: Typeface? = null, + family: String? = null, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanTypeface(typeface, family, replaceRule) } + + /** + * [TextAppearanceSpan] 设置字体效果spanTypeface + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param size 文本大小 + * @param color 文本颜色 + * @param family 字体集 + * @param linkColor 链接颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun textAppearance( + @TextStyle style: Int = Typeface.NORMAL, + @Px size: Int = -1, + @ColorInt color: Int? = null, + family: String? = null, + linkColor: ColorStateList? = null, + replaceRule: Any? = null + ): Span = runOnSelf { + spannableCache?.spanTextAppearance( + style, + size, + color, + family, + linkColor, + replaceRule + ) + } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param color 文本颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun color( + @ColorInt color: Int, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanColor(color, replaceRule) } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param colorString 文本颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun color( + colorString: String, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanColor(Companion.color(colorString), replaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param color 背景颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun background( + @ColorInt color: Int, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanBackground(color, replaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun background( + colorString: String, + replaceRule: Any? = null + ): Span = + runOnSelf { spannableCache?.spanBackground(Companion.color(colorString), replaceRule) } + + /** + * [CenterImageSpan] 图片 + * + * @param drawable [Drawable] + * @param source [Drawable] Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + drawable: Drawable, + source: String? = null, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + drawable, + source, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param uri 图片 Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + context: Context, + uri: Uri, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + context, + uri, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param resourceId 图片Id + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + context: Context, + @DrawableRes resourceId: Int, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + context, + resourceId, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param bitmap [Bitmap] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + context: Context, + bitmap: Bitmap, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + context, + bitmap, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [GlideImageSpan] 图片 + * + * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新 + * @param url 图片地址参见 [Glide.with(view).load(url)] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun glide( + view: TextView, + url: Any, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: GlideImageSpan.Align? = null, + loopCount: Int? = null, + requestOption: RequestOptions? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanGlide( + view, + url, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: GlideImageSpan.Align.CENTER, + loopCount, + requestOption, + replaceRule + ) + } + + /** + * [ScaleXSpan] X轴文本缩放 + * + * @param proportion 水平(X轴)缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun scaleX( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanScaleX(proportion, replaceRule) } + + + /** + * [MaskFilterSpan] 设置文本蒙版效果 + * + * @param filter 蒙版效果 [MaskFilter] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun maskFilter( + filter: MaskFilter, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanMaskFilter(filter, replaceRule) } + + /** + * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果 + * + * @param radius 模糊半径 + * @param style 模糊效果 [BlurMaskFilter.Blur] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun blurMask( + @FloatRange(from = 0.0) radius: Float, + style: BlurMaskFilter.Blur? = null, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanBlurMask(radius, style, replaceRule) } + + /** + * [SuperscriptSpan] 设置文本为上标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun superscript( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanSuperscript(replaceRule) } + + /** + * [SubscriptSpan] 设置文本为下标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun subscript( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanSubscript(replaceRule) } + + /** + * [AbsoluteSizeSpan] 设置文本绝对大小 + * + * @param size 文本大小 + * @param dp true = [size] dp, false = [size] px + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun absoluteSize( + size: Int, + dp: Boolean = true, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanAbsoluteSize(size, dp, replaceRule) } + + /** + * [RelativeSizeSpan] 设置文本相对大小 + * + * @param proportion 文本缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun relativeSize( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanRelativeSize(proportion, replaceRule) } + + /** + * [StrikethroughSpan] 设置文本删除线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun strikethrough( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanStrikethrough(replaceRule) } + + /** + * [UnderlineSpan] 设置文本下划线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun underline( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanUnderline(replaceRule) } + + /** + * [URLSpan] 设置文本超链接 + * + * 需配合[TextView.activateClick]使用 + * @param url 超链接地址 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun url( + url: String, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanURL(url, replaceRule) } + + /** + * [SuggestionSpan] 设置文本输入提示 + * + * @param context [Context] + * @param suggestions 提示规则文本数组 + * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION] + * @param locale 语言区域设置 + * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun suggestion( + context: Context, + suggestions: Array, + flags: Int = SuggestionSpan.FLAG_EASY_CORRECT or SuggestionSpan.FLAG_AUTO_CORRECTION, + locale: Locale? = null, + notificationTargetClass: Class<*>? = null, + replaceRule: Any? = null + ): Span = runOnSelf { + spannableCache?.spanSuggestion( + context, + suggestions = suggestions, + flags, + locale, + notificationTargetClass, + replaceRule + ) + } + + /** + * [SimpleClickableSpan] 设置文本点击效果 + * + * @param color 文本颜色 + * @param backgroundColor 背景颜色 + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param config 附加配置 [SimpleClickableConfig] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * @param onClick [OnSpanClickListener] 点击回调 + */ + @JvmOverloads + fun clickable( + @ColorInt color: Int? = null, + @ColorInt backgroundColor: Int? = null, + @TextStyle style: Int? = null, + config: SimpleClickableConfig? = null, + replaceRule: Any? = null, + onClick: OnSpanClickListener? = null + ): Span = runOnSelf { + spannableCache?.spanClickable( + color, + backgroundColor, + style, + config, + replaceRule, + onClick + ) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param color 间距填充颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun margin( + @Px width: Int, + @ColorInt color: Int = Color.TRANSPARENT, + replaceRule: Any? = null + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanMargin(width, color, replaceRule) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param colorString 间距填充颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun margin( + @Px width: Int, + colorString: String?, + replaceRule: Any? = null + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanMargin(width, colorString?.takeIf(String::isNotBlank)?.color ?: Color.TRANSPARENT, replaceRule) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 竖线颜色 + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun quote( + @ColorInt color: Int, + @Px @IntRange(from = 0) stripeWidth: Int = 10, + @Px @IntRange(from = 0) gapWidth: Int = 0 + ): Span = runOnSelf { + spannableCache?.spanQuote(color, stripeWidth, gapWidth) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 竖线颜色 #RRGGBB #AARRGGBB + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun quote( + colorString: String, + @IntRange(from = 0) stripeWidth: Int = 10, + @IntRange(from = 0) gapWidth: Int = 0 + ): Span = runOnSelf { + spannableCache?.spanQuote(colorString.color, stripeWidth, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 圆形颜色 + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun bullet( + @ColorInt color: Int, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ): Span = runOnSelf { + spannableCache?.spanBullet(color, bulletRadius, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 圆形颜色 #RRGGBB #AARRGGBB + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun bullet( + colorString: String, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ): Span = runOnSelf { + spannableCache?.spanBullet(colorString.color, bulletRadius, gapWidth) + } + + /** + * [AlignmentSpan] 设置段落对齐方式 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE] + */ + fun alignment( + align: Layout.Alignment + ): Span = runOnSelf { + spannableCache?.spanAlignment(align) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 背景颜色 + */ + fun lineBackground( + @ColorInt color: Int + ): Span = runOnSelf { + spannableCache?.spanLineBackground(color) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + */ + fun lineBackground( + colorString: String + ): Span = runOnSelf { + spannableCache?.spanLineBackground(colorString.color) + } + + /** + * [LeadingMarginSpan] 设置段落文本缩进 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param firstLines 首行行数. 与[firstMargin]关联 + * @param firstMargin 首行左边距(缩进) + * @param restMargin 剩余行(非首行)左边距(缩进) + */ + @JvmOverloads + fun leadingMargin( + @IntRange(from = 1L) firstLines: Int, + @Px firstMargin: Int, + @Px restMargin: Int = 0 + ): Span = runOnSelf { + spannableCache?.spanLeadingMargin(firstLines, firstMargin, restMargin) + } + + /** + * [LineHeightSpan] 设置段落行高 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param height 行高 + */ + fun lineHeight( + @Px @IntRange(from = 1L) height: Int + ): Span = runOnSelf { + spannableCache?.spanLineHeight(height) + } + + /** + * [ParagraphBitmapSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param bitmap [Bitmap] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + @JvmOverloads + fun imageParagraph( + bitmap: Bitmap, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ): Span = runOnSelf { + spannableCache?.spanImageParagraph(bitmap, padding, useTextViewSize, size) + } + + /** + * [ParagraphDrawableSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param drawable [Drawable] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + @JvmOverloads + fun imageParagraph( + drawable: Drawable, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ): Span = runOnSelf { + spannableCache?.spanImageParagraph(drawable, padding, useTextViewSize, size) + } + + /** + * 自定义字符样式 + * + * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun custom( + style: T, + replaceRule: Any? = null, + ): Span = runOnSelf { + spannableCache?.spanCustom(style, replaceRule) + } + + /** + * 自定义段落样式 + * + * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效 + */ + @JvmOverloads + fun custom( + style: T, + replaceRule: Any? = null, + ): Span = runOnSelf { + spannableCache?.spanCustom(style, replaceRule) + } + + companion object { + /** + * 构建Span + * @see [Span] + */ + @JvmStatic + fun create(): Span = Span() + + // + /** + * 适配Java不支持CharSequence operator plus + * eg. `SpanExtension.spannedString(spanImage(...),spanColor(..))` + */ + @JvmStatic + fun spannedString(vararg texts: CharSequence): SpannedString = buildSpannedString { + texts.forEach(this::append) + } + + /** + * 兼容Java适配 @see [toReplaceRule] + * + * @param replaceString 查找的字符串或正则文本 + * @param isRegex [replaceString]是否为正则 + * @param matchIndex 单一匹配位置 ([matchRange]不为null时优先使用[matchRange]) + * @param matchRange 匹配范围 + * @param newString 替换文本(null 为不替换) + * @param replacementMatch 匹配时回调 + */ + @JvmStatic + @JvmOverloads + fun toReplaceRule( + replaceString: String, + isRegex: Boolean = false, + matchIndex: Int? = null, + matchRange: kotlin.ranges.IntRange? = null, + newString: CharSequence? = null, + replacementMatch: OnSpanReplacementMatch? = null + ): ReplaceRule = + replaceString.toReplaceRule( + isRegex, + matchIndex, + matchRange, + newString, + replacementMatch + ) + + /** + * 快速构建 [DrawableSize] + */ + @JvmStatic + @JvmOverloads + fun drawableSize( + size: Int, + @ConversionUnit unit: Int = ConversionUnit.NOT_CONVERT, + ): DrawableSize = + size.let { + when (unit) { + ConversionUnit.SP -> it.sp + ConversionUnit.DP -> it.dp + else -> it + } + }.drawableSize + + + /** + * dp 2 px + */ + @JvmStatic + fun dp(value: Int): Int = value.dp + + /** + * sp 2 px + */ + @JvmStatic + fun sp(value: Int): Int = value.sp + + @JvmStatic + fun color(colorString: String): Int = colorString.color + + /** + * 删除所有[CharacterStyle] Span + */ + @JvmStatic + fun removeAllSpans(span: Spannable) { + span.removeAllSpans() + } + + @JvmStatic + fun removeSpans(text: CharSequence, type: Class<*>): CharSequence = + (if (text is Spannable) text else SpannableString(text)).apply { + val allSpans = getSpans(0, length, type) + for (span in allSpans) { + removeSpan(span) + } + } + + /** + * 兼容Java适配 @see [TextView.activateClick] + * + * 配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param textView 需要配置点击效果的[TextView] + * @param background 是否显示点击背景 + */ + @JvmStatic + @JvmOverloads + fun activateClick(textView: TextView, background: Boolean = true): TextView = + textView.activateClick(background) + // + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt new file mode 100644 index 0000000..087e71a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt @@ -0,0 +1,812 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") + +package com.remax.visualnovel.utils.spannablex + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.* +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* +import android.widget.TextView +import androidx.annotation.* +import androidx.annotation.IntRange +import com.bumptech.glide.request.RequestOptions +import com.drake.spannable.span.CenterImageSpan +import com.drake.spannable.span.GlideImageSpan +import com.drake.spannable.span.MarginSpan +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize +import com.remax.visualnovel.utils.spannablex.utils.color +import java.util.* + +/** + * DSL Spannable + * + * sample: ```kotlin + * TextView.text = spannable { + * "this is real text.".text() + * "spannable".color(Color.BLUE).style(Typeface.BOLD) + * } + * ``` + */ +class SpanDsl private constructor( + private val text: CharSequence?, + private val globalReplaceRule: Any? +) { + + /** + * [text] SpannedString + */ + var textSpannable: Spanned? = text?.let { + when (text) { + is Spanned -> text + else -> SpannedString(text) + } + } + private set + + private val spannableBuilder = SpannableStringBuilder() + + internal fun spannable(): SpannableStringBuilder = + spannableBuilder.apply { textSpannable?.let { ts -> insert(0, ts) } } + + /** + * 设置单个Span + */ + private fun Any?.singleSpan( + autoPlaceholder: Boolean = false, + span: CharSequence.() -> Spanned + ) { + when { + this is CharSequence -> { + spannableBuilder.append(span.invoke(this)) + } + autoPlaceholder -> { + spannableBuilder.append(span.invoke(IMAGE_SPAN_TAG)) + } + textSpannable != null -> { + textSpannable = span.invoke(textSpannable!!) + } + } + } + + /** + * 添加文本(无Spannable效果) + */ + fun CharSequence?.text() { + this?.let(spannableBuilder::append) + } + + /** + * 为 @receiver 设置多个Span + */ + fun CharSequence?.span(replaceRule: Any? = null, span: SpanDsl.() -> Unit = {}) { + mixed(replaceRule, span) + } + + /** + * 为 @receiver 设置多个Span + */ + fun CharSequence?.mixed(replaceRule: Any? = null, span: SpanDsl.() -> Unit = {}) { + spannableBuilder.append( + create(this, replaceRule ?: globalReplaceRule).apply(span).spannable() + ) + } + + /** + * 换行(可自行处理`\n`) + */ + fun T?.newline(@IntRange(from = 1L) lines: Int = 1): CharSequence? = + run { + val newlines = if (lines > 1) { + buildString { + repeat(lines) { append("\n") } + } + } else "\n" + when (this) { + is SpannableStringBuilder -> append(newlines) + is Spanned -> SpannableStringBuilder(this).append(newlines) + is String -> "${this}$newlines" + is CharSequence -> "${this}$newlines" + else -> { + spannableBuilder.append(newlines) + null + } + } + } + + /** + * [StyleSpan] 设置文本样式 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.style( + @TextStyle style: Int, + replaceRule: Any? = null + ) = singleSpan { spanStyle(style, replaceRule ?: globalReplaceRule) } + + /** + * [TypefaceSpan] 设置字体样式 + * + * @param typeface 字体(API>=28) + * @param family 字体集 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.typeface( + typeface: Typeface? = null, + family: String? = null, + replaceRule: Any? = null + ) = singleSpan { spanTypeface(typeface, family, replaceRule ?: globalReplaceRule) } + + /** + * [TextAppearanceSpan] 设置字体效果spanTypeface + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param size 文本大小 + * @param color 文本颜色 + * @param family 字体集 + * @param linkColor 链接颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.textAppearance( + @TextStyle style: Int = Typeface.NORMAL, + @Px size: Int = -1, + @ColorInt color: Int? = null, + family: String? = null, + linkColor: ColorStateList? = null, + replaceRule: Any? = null + ) = singleSpan { + spanTextAppearance( + style, + size, + color, + family, + linkColor, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param color 文本颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.color( + @ColorInt color: Int, + replaceRule: Any? = null + ) = singleSpan { spanColor(color, replaceRule ?: globalReplaceRule) } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param colorString 文本颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.color( + colorString: String, + replaceRule: Any? = null + ) = singleSpan { spanColor(colorString.color, replaceRule ?: globalReplaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param color 背景颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.background( + @ColorInt color: Int, + replaceRule: Any? = null + ) = singleSpan { spanBackground(color, replaceRule ?: globalReplaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.background( + colorString: String, + replaceRule: Any? = null + ) = singleSpan { spanBackground(colorString.color, replaceRule ?: globalReplaceRule) } + + /** + * [CenterImageSpan] 图片 + * + * @param drawable [Drawable] + * @param source [Drawable] Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + drawable: Drawable, + source: String? = null, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + drawable, + source, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param uri 图片 Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + context: Context, + uri: Uri, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + context, + uri, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param resourceId 图片Id + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + context: Context, + @DrawableRes resourceId: Int, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + context, + resourceId, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param bitmap [Bitmap] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + context: Context, + bitmap: Bitmap, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + context, + bitmap, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [GlideImageSpan] 图片 + * + * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新 + * @param url 图片地址参见 [Glide.with(view).load(url)] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.glide( + view: TextView, + url: Any, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: GlideImageSpan.Align = GlideImageSpan.Align.CENTER, + loopCount: Int? = null, + requestOption: RequestOptions? = null, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanGlide( + view, + url, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + loopCount, + requestOption, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [ScaleXSpan] X轴文本缩放 + * + * @param proportion 水平(X轴)缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.scaleX( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ) = singleSpan { + spanScaleX(proportion, replaceRule ?: globalReplaceRule) + } + + /** + * [MaskFilterSpan] 设置文本蒙版效果 + * + * @param filter 蒙版效果 [MaskFilter] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.maskFilter( + filter: MaskFilter, + replaceRule: Any? = null + ) = singleSpan { + spanMaskFilter(filter, replaceRule ?: globalReplaceRule) + } + + /** + * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果 + * + * @param radius 模糊半径 + * @param style 模糊效果 [BlurMaskFilter.Blur] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.blurMask( + @FloatRange(from = 0.0) radius: Float, + style: BlurMaskFilter.Blur = BlurMaskFilter.Blur.NORMAL, + replaceRule: Any? = null + ) = singleSpan { + spanBlurMask(radius, style, replaceRule ?: globalReplaceRule) + } + + /** + * [SuperscriptSpan] 设置文本为上标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.superscript(replaceRule: Any? = null) = singleSpan { + spanSuperscript(replaceRule ?: globalReplaceRule) + } + + /** + * [SubscriptSpan] 设置文本为下标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.subscript(replaceRule: Any? = null) = singleSpan { + spanSubscript(replaceRule ?: globalReplaceRule) + } + + /** + * [AbsoluteSizeSpan] 设置文本绝对大小 + * + * @param size 文本大小 + * @param dp true = [size] dp, false = [size] px + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.absoluteSize( + size: Int, + dp: Boolean = true, + replaceRule: Any? = null + ) = + singleSpan { + spanAbsoluteSize(size, dp, replaceRule ?: globalReplaceRule) + } + + /** + * [RelativeSizeSpan] 设置文本相对大小 + * + * @param proportion 文本缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.relativeSize( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ) = singleSpan { + spanRelativeSize(proportion, replaceRule ?: globalReplaceRule) + } + + /** + * [StrikethroughSpan] 设置文本删除线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.strikethrough(replaceRule: Any? = null) = singleSpan { + spanStrikethrough(replaceRule ?: globalReplaceRule) + } + + /** + * [UnderlineSpan] 设置文本下划线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.underline(replaceRule: Any? = null) = singleSpan { + spanUnderline(replaceRule ?: globalReplaceRule) + } + + /** + * [URLSpan] 设置文本超链接 + * + * 需配合[TextView.activateClick]使用 + * @param url 超链接地址 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.url(url: String, replaceRule: Any? = null) = singleSpan { + spanURL(url, replaceRule ?: globalReplaceRule) + } + + /** + * [SuggestionSpan] 设置文本输入提示 + * + * @param context [Context] + * @param suggestions 提示规则文本数组 + * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION] + * @param locale 语言区域设置 + * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.suggestion( + context: Context, + suggestions: Array, + flags: Int = SuggestionSpan.FLAG_EASY_CORRECT or SuggestionSpan.FLAG_AUTO_CORRECTION, + locale: Locale? = null, + notificationTargetClass: Class<*>? = null, + replaceRule: Any? = null + ) = singleSpan { + spanSuggestion( + context, + suggestions, + flags, + locale, + notificationTargetClass, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [SimpleClickableSpan] 设置文本点击效果 + * + * @param color 文本颜色 + * @param backgroundColor 背景颜色 + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param config 附加配置 [SimpleClickableConfig] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * @param onClick [OnSpanClickListener] 点击回调 + */ + fun Any?.clickable( + @ColorInt color: Int? = null, + @ColorInt backgroundColor: Int? = null, + @TextStyle style: Int? = null, + config: SimpleClickableConfig? = null, + replaceRule: Any? = null, + onClick: OnSpanClickListener? = null + ) = singleSpan { + spanClickable( + color, + backgroundColor, + style, + config, + replaceRule ?: globalReplaceRule, + onClick + ) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param color 间距填充颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.margin( + @Px width: Int, + @ColorInt color: Int = Color.TRANSPARENT, + replaceRule: Any? = null + ) = singleSpan(replaceRule == null) { + spanMargin(width, color, replaceRule) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param colorString 间距填充颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.margin( + @Px width: Int, + colorString: String, + replaceRule: Any? = null + ) = singleSpan(replaceRule == null) { + spanMargin(width, colorString.color, replaceRule) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 竖线颜色 + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.quote( + @ColorInt color: Int, + @Px @IntRange(from = 0) stripeWidth: Int = 10, + @Px @IntRange(from = 0) gapWidth: Int = 0 + ) = singleSpan { + spanQuote(color, stripeWidth, gapWidth) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 竖线颜色 #RRGGBB #AARRGGBB + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.quote( + colorString: String, + @IntRange(from = 0) stripeWidth: Int = 10, + @IntRange(from = 0) gapWidth: Int = 0 + ) = singleSpan { + spanQuote(colorString.color, stripeWidth, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 圆形颜色 + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.bullet( + @ColorInt color: Int, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ) = singleSpan { + spanBullet(color, bulletRadius, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 圆形颜色 #RRGGBB #AARRGGBB + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.bullet( + colorString: String, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ) = singleSpan { + spanBullet(colorString.color, bulletRadius, gapWidth) + } + + /** + * [AlignmentSpan] 设置段落对齐方式 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE] + */ + fun Any?.alignment( + align: Layout.Alignment + ) = singleSpan { + spanAlignment(align) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 背景颜色 + */ + fun Any?.lineBackground( + @ColorInt color: Int, + ) = singleSpan { + spanLineBackground(color) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + */ + fun Any?.lineBackground( + colorString: String, + ) = singleSpan { + spanLineBackground(colorString.color) + } + + /** + * [LeadingMarginSpan] 设置段落文本缩进 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param firstLines 首行行数. 与[firstMargin]关联 + * @param firstMargin 首行左边距(缩进) + * @param restMargin 剩余行(非首行)左边距(缩进) + */ + fun Any?.leadingMargin( + @IntRange(from = 1L) firstLines: Int, + @Px firstMargin: Int, + @Px restMargin: Int = 0 + ) = singleSpan { + spanLeadingMargin(firstLines, firstMargin, restMargin) + } + + /** + * [LineHeightSpan] 设置段落行高 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param height 行高 + */ + fun Any?.lineHeight( + @Px @IntRange(from = 1L) height: Int + ) = singleSpan { + spanLineHeight(height) + } + + /** + * [ParagraphBitmapSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param bitmap [Bitmap] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + fun Any?.imageParagraph( + bitmap: Bitmap, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ) = singleSpan { + spanImageParagraph(bitmap, padding, useTextViewSize, size) + } + + /** + * [ParagraphDrawableSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param drawable [Drawable] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + fun Any?.imageParagraph( + drawable: Drawable, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ) = singleSpan { + spanImageParagraph(drawable, padding, useTextViewSize, size) + } + + /** + * 自定义字符样式 + * + * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.custom( + style: T, + replaceRule: Any? = null, + ) = singleSpan { + spanCustom(style, replaceRule) + } + + /** + * 自定义段落样式 + * + * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效 + */ + fun Any?.custom( + style: T, + replaceRule: Any? = null, + ) = singleSpan { + spanCustom(style, replaceRule) + } + + companion object { + /** + * @see [SpanDsl] + */ + fun create(text: CharSequence?, replaceRule: Any?): SpanDsl = + SpanDsl(text, replaceRule) + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt new file mode 100644 index 0000000..233b996 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt @@ -0,0 +1,826 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("SpanInternal") +@file:Suppress("unused") + +package com.remax.visualnovel.utils.spannablex + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.BlurMaskFilter +import android.graphics.MaskFilter +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.* +import android.widget.TextView +import androidx.annotation.* +import androidx.annotation.IntRange +import com.bumptech.glide.request.RequestOptions +import com.drake.spannable.replaceSpan +import com.drake.spannable.setSpan +import com.drake.spannable.span.CenterImageSpan +import com.drake.spannable.span.GlideImageSpan +import com.drake.spannable.span.MarginSpan +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener +import com.remax.visualnovel.utils.spannablex.span.LeadingMarginSpan +import com.remax.visualnovel.utils.spannablex.span.ParagraphBitmapSpan +import com.remax.visualnovel.utils.spannablex.span.ParagraphDrawableSpan +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyBulletSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyLineBackgroundSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyLineHeightSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyQuoteSpan +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize +import com.remax.visualnovel.utils.spannablex.utils.drawableSize +import com.remax.visualnovel.utils.spannablex.utils.textSizeInt +import java.util.* + +// +/** + * ImageSpan Text标识 + */ +internal const val IMAGE_SPAN_TAG = " " + +private const val UNKNOWN_REPLACE_RULES = + "Unknown replace rules. please use `String(list/array)`, `Regex(list/array)`, `ReplaceRule(list/array)`." + +/** + * [CenterImageSpan] 适配 [Drawable] size + */ +private fun CenterImageSpan.setupSize( + useTextViewSize: TextView?, + size: DrawableSize? +): CenterImageSpan = apply { + useTextViewSize?.textSizeInt?.let { textSize -> + setDrawableSize(textSize, textSize) + } ?: size?.let { drawableSize -> + setDrawableSize(drawableSize.width, drawableSize.height) + } +} + +/** + * [CenterImageSpan] 适配 [Drawable] margin + * 这里多做判断,是防止[CenterImageSpan.setMarginHorizontal] 做多余的`drawableRef?.clear()` + */ +private fun CenterImageSpan.setupMarginHorizontal( + left: Int?, + right: Int? +): CenterImageSpan = apply { + if (left != null || right != null) { + setMarginHorizontal(left ?: 0, right ?: 0) + } +} + +/** + * [GlideImageSpan] 适配 [Drawable] size + */ +private fun GlideImageSpan.setupSize( + useTextViewSize: TextView?, + size: DrawableSize? +): GlideImageSpan = apply { + useTextViewSize?.textSizeInt?.let { textSize -> + setDrawableSize(textSize, textSize) + } ?: size?.let { drawableSize -> + setDrawableSize(drawableSize.width, drawableSize.height) + } +} + +/** + * [GlideImageSpan] 适配 [Drawable] margin + * 这里多做判断,是防止[GlideImageSpan.setMarginHorizontal] 做多余的`drawableRef?.set(null)` + */ +private fun GlideImageSpan.setupMarginHorizontal( + left: Int?, + right: Int? +): GlideImageSpan = apply { + if (left != null || right != null) { + setMarginHorizontal(left ?: 0, right ?: 0) + } +} + +/** + * 适配[setSpan] 的返回值为 [Spannable], 以便进行plus操作 + */ +private fun CharSequence.span(what: Any?): Spannable = setSpan(what) as Spannable + +/** + * 适配[replaceSpan] 的返回值为 [Spannable], 以便进行plus操作 + */ +private fun CharSequence.spanReplace( + regex: Regex, + quoteGroup: Boolean = false, + startIndex: Int = 0, + replacement: (MatchResult) -> Any? +): Spannable { + return (replaceSpan(regex, quoteGroup, startIndex, replacement = replacement) as? Spannable) ?: SpannableStringBuilder(this) +} + +/** + * 正则 [Regex] 列表替换 + */ +private fun CharSequence.replaceRegexList( + ruleList: List, + createWhat: (matchText: String) -> Any +): Spannable? { + var span: CharSequence? = null + ruleList.forEach { replace -> + span = (span ?: this).spanReplace(replace) { + createWhat.invoke(it.value) + } + } + return if (span is Spannable) span as Spannable else SpannableString.valueOf(span) +} + +/** + * 组合替换规则 [ReplaceRule] 列表替换 + */ +private fun CharSequence.replaceReplaceRuleList( + ruleList: List, + createWhat: (matchText: String) -> Any +): Spannable? { + var span: CharSequence? = null + ruleList.forEach { replace -> + var currentMatchCount = 0 + span = (span ?: this).spanReplace(replace.replaceRules) { + if (replace.matchRange == null || currentMatchCount++ in replace.matchRange) { + replace.replacementMatch?.onMatch(it) + val characterStyle = createWhat.invoke(it.value) + replace.newString?.span(characterStyle) ?: characterStyle + } else null + } + } + + return if (span is Spannable) span as Spannable else SpannableString.valueOf(span) +} + +/** + * [setSpan] or [replaceRule] + */ +@Suppress("UNCHECKED_CAST") +private fun CharSequence.setOrReplaceSpan( + replaceRule: Any?, + createWhat: (matchText: String) -> Any +): Spannable = replaceRule?.let { rule -> + when (rule) { + // + is String -> spanReplace(Regex.escape(rule).toRegex()) { + createWhat.invoke(it.value) + } + + is Regex -> spanReplace(rule) { + createWhat.invoke(it.value) + } + + is ReplaceRule -> replaceReplaceRuleList(listOf(rule), createWhat) + // + + // + is Array<*> -> if (rule.isEmpty()) { + span(createWhat.invoke(this.toString())) + } else { + when (rule[0]) { + /* String */ + is String -> replaceRegexList( + (rule as Array).map { Regex.escape(it).toRegex() }, + createWhat + ) + /* 正则 */ + is Regex -> replaceRegexList( + (rule as Array).toList(), + createWhat + ) + + /* ReplaceRule */ + is ReplaceRule -> replaceReplaceRuleList( + (rule as Array).toList(), + createWhat + ) + + else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES) + } + } + // + + // + is List<*> -> if (rule.isEmpty()) { + span(createWhat.invoke(this.toString())) + } else { + when (rule[0]) { + /* String */ + is String -> replaceRegexList( + (rule as List).map { Regex.escape(it).toRegex() }, + createWhat + ) + /* 正则 */ + is Regex -> replaceRegexList( + (rule as List), + createWhat + ) + /* ReplaceRule */ + is ReplaceRule -> replaceReplaceRuleList( + (rule as List), + createWhat + ) + + else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES) + } + } + // + else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES) + } +} ?: span(createWhat.invoke(this.toString())) + +// + +// +/** + * [StyleSpan] 设置文本样式 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanStyle( + @TextStyle style: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + StyleSpan(style) +} + +/** + * [TypefaceSpan] 设置字体样式 + * + * @param typeface 字体(API>=28) + * @param family 字体集 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanTypeface( + typeface: Typeface?, + family: String?, + replaceRule: Any? +): Spannable = (if (typeface != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + TypefaceSpan(typeface) +} else TypefaceSpan(family)).let { typefaceSpan -> + setOrReplaceSpan(replaceRule) { typefaceSpan } +} + +/** + * [TextAppearanceSpan] 设置字体效果 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param size 文本大小 + * @param color 文本颜色 + * @param family 字体集 + * @param linkColor 链接颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanTextAppearance( + @TextStyle style: Int = Typeface.NORMAL, + @Px size: Int = -1, + @ColorInt color: Int?, + family: String?, + linkColor: ColorStateList?, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + TextAppearanceSpan(family, style, size, color?.let(ColorStateList::valueOf), linkColor) +} + +/** + * [ForegroundColorSpan] 文本颜色 + * + * @param color 文本颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanColor( + @ColorInt color: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + ForegroundColorSpan(color) +} + +/** + * [BackgroundColorSpan] 背景颜色 + * + * @param color 背景颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanBackground( + @ColorInt color: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + BackgroundColorSpan(color) +} + +/** + * [CenterImageSpan] 图片 + * + * @param drawable [Drawable] + * @param source [Drawable] Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + drawable: Drawable, + source: String?, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + (source?.let { + CenterImageSpan(drawable, it) + } ?: CenterImageSpan(drawable)).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param uri 图片 Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + context: Context, + uri: Uri, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + CenterImageSpan(context, uri).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param resourceId 图片Id + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + context: Context, + @DrawableRes resourceId: Int, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + CenterImageSpan(context, resourceId).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param bitmap [Bitmap] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + context: Context, + bitmap: Bitmap, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + CenterImageSpan(context, bitmap).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [GlideImageSpan] 图片 + * + * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新 + * @param url 图片地址参见 [Glide.with(view).load(url)] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanGlide( + view: TextView, + url: Any, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: GlideImageSpan.Align, + loopCount: Int?, + requestOption: RequestOptions?, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + GlideImageSpan(view, url).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) + .apply { + loopCount?.let(::setLoopCount) + requestOption?.let(::setRequestOption) + } +} + +/** + * [ScaleXSpan] X轴文本缩放 + * + * @param proportion 水平(X轴)缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanScaleX( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + ScaleXSpan(proportion) +} + +/** + * [MaskFilterSpan] 设置文本蒙版效果 + * + * @param filter 蒙版效果 [MaskFilter] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanMaskFilter( + filter: MaskFilter, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + MaskFilterSpan(filter) +} + +/** + * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果 + * + * @param radius 模糊半径 + * @param style 模糊效果 [BlurMaskFilter.Blur] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanBlurMask( + @FloatRange(from = 0.0) radius: Float, + style: BlurMaskFilter.Blur?, + replaceRule: Any? +): Spannable = + spanMaskFilter(BlurMaskFilter(radius, style ?: BlurMaskFilter.Blur.NORMAL), replaceRule) + +/** + * [SuperscriptSpan] 设置文本为上标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanSuperscript( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + SuperscriptSpan() +} + +/** + * [SubscriptSpan] 设置文本为下标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanSubscript( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + SubscriptSpan() +} + +/** + * [AbsoluteSizeSpan] 设置文本绝对大小 + * + * @param size 文本大小 + * @param dp true = [size] dp, false = [size] px + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanAbsoluteSize( + size: Int, + dp: Boolean, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + AbsoluteSizeSpan(size, dp) +} + +/** + * [RelativeSizeSpan] 设置文本相对大小 + * + * @param proportion 文本缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanRelativeSize( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + RelativeSizeSpan(proportion) +} + +/** + * [StrikethroughSpan] 设置文本删除线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanStrikethrough( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + StrikethroughSpan() +} + +/** + * [UnderlineSpan] 设置文本下划线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanUnderline( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + UnderlineSpan() +} + +/** + * [URLSpan] 设置文本超链接 + * + * 需配合[TextView.activateClick]使用 + * @param url 超链接地址 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanURL( + url: String, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + URLSpan(url) +} + +/** + * [SuggestionSpan] 设置文本输入提示 + * + * @param context [Context] + * @param suggestions 提示规则文本数组 + * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION] + * @param locale 语言区域设置 + * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanSuggestion( + context: Context, + suggestions: Array, + flags: Int, + locale: Locale?, + notificationTargetClass: Class<*>?, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + SuggestionSpan(context, locale, suggestions, flags, notificationTargetClass) +} + +/** + * [SimpleClickableSpan] 设置文本点击效果 + * + * @param color 文本颜色 + * @param backgroundColor 背景颜色 + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param config 附加配置 [SimpleClickableConfig] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * @param onClick [OnSpanClickListener] 点击回调 + */ +internal fun CharSequence.spanClickable( + @ColorInt color: Int?, + @ColorInt backgroundColor: Int?, + @TextStyle style: Int?, + config: SimpleClickableConfig?, + replaceRule: Any?, + onClick: OnSpanClickListener? +): Spannable = setOrReplaceSpan(replaceRule) { matchText -> + SimpleClickableSpan(color, backgroundColor, style, config) { + onClick?.onClick(it, matchText) + } +} + +/** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param color 间距填充颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanMargin( + @Px width: Int, + @ColorInt color: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + MarginSpan(width, color) +} + +/** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 竖线颜色 + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ +internal fun CharSequence.spanQuote( + @ColorInt color: Int, + @Px @IntRange(from = 0) stripeWidth: Int, + @Px @IntRange(from = 0) gapWidth: Int, +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + QuoteSpan(color, stripeWidth, gapWidth) + } else { + LegacyQuoteSpan(color, stripeWidth, gapWidth) + } +} + +/** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 圆形颜色 + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ +internal fun CharSequence.spanBullet( + @ColorInt color: Int, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int, +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + BulletSpan(gapWidth, color, bulletRadius) + } else { + LegacyBulletSpan(color, bulletRadius, gapWidth) + } +} + +/** + * [AlignmentSpan] 设置段落对齐方式 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE] + */ +internal fun CharSequence.spanAlignment( + align: Layout.Alignment +): Spannable = setOrReplaceSpan(null) { + AlignmentSpan.Standard(align) +} + +/** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 背景颜色 + */ +internal fun CharSequence.spanLineBackground( + @ColorInt color: Int +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LineBackgroundSpan.Standard(color) + } else { + LegacyLineBackgroundSpan(color) + } +} + +/** + * [LeadingMarginSpan] 设置段落文本缩进 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param firstLines 首行行数. 与[firstMargin]关联 + * @param firstMargin 首行左边距(缩进) + * @param restMargin 剩余行(非首行)左边距(缩进) + */ +internal fun CharSequence.spanLeadingMargin( + @IntRange(from = 1L) firstLines: Int, + @Px firstMargin: Int, + @Px restMargin: Int +): Spannable = setOrReplaceSpan(null) { + LeadingMarginSpan(firstLines, firstMargin, restMargin) +} + +/** + * [LineHeightSpan] 设置段落行高 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param height 行高 + */ +internal fun CharSequence.spanLineHeight( + @Px @IntRange(from = 1L) height: Int +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LineHeightSpan.Standard(height) + } else { + LegacyLineHeightSpan(height) + } +} + +/** + * [ParagraphBitmapSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param bitmap [Bitmap] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ +internal fun CharSequence.spanImageParagraph( + bitmap: Bitmap, + @Px padding: Int, + useTextViewSize: TextView?, + size: DrawableSize? +): Spannable = setOrReplaceSpan(null) { + ParagraphBitmapSpan(bitmap, useTextViewSize?.textSizeInt?.drawableSize ?: size, padding) +} + +/** + * [ParagraphDrawableSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param drawable [Drawable] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ +internal fun CharSequence.spanImageParagraph( + drawable: Drawable, + @Px padding: Int, + useTextViewSize: TextView?, + size: DrawableSize? +): Spannable = setOrReplaceSpan(null) { + ParagraphDrawableSpan(drawable, useTextViewSize?.textSizeInt?.drawableSize ?: size, padding) +} + +/** + * 自定义字符样式 + * + * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanCustom( + style: T, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + style +} + +/** + * 自定义段落样式 + * + * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效 + */ +internal fun CharSequence.spanCustom( + style: T, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + style +} + +// \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt new file mode 100644 index 0000000..cdb4865 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex + +import android.R +import android.app.Activity +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.SpannedString +import android.text.method.LinkMovementMethod +import android.text.style.CharacterStyle +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.drake.spannable.movement.ClickableMovementMethod + +/** + * 构建Spannable + * @see [SpanDsl] + */ +fun Any?.spannable(builderAction: SpanDsl.() -> Unit): SpannableStringBuilder = + SpanDsl.create( + text = if (this is CharSequence) this else null, replaceRule = null + ).apply(builderAction).spannable() + +// +/** + * 删除指定Span + */ +inline fun CharSequence.removeSpans(): CharSequence = + (if (this is Spannable) this else SpannableString(this)).apply { + val allSpans = getSpans(0, length, T::class.java) + for (span in allSpans) { + removeSpan(span) + } + } + +/** + * 删除所有[CharacterStyle] Span + */ +fun CharSequence.removeAllSpans(): CharSequence = + (if (this is Spannable) this else SpannableString(this)).apply { + val allSpans = getSpans(0, length, CharacterStyle::class.java) + for (span in allSpans) { + removeSpan(span) + } + } + +// + +// +/** + * 配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + */ +fun TextView.activateClick(background: Boolean = true): TextView = apply { + movementMethod = if (background) LinkMovementMethod.getInstance() else ClickableMovementMethod.getInstance() +} + +/** + * 循环获取控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun View?.autoActivateClick(background: Boolean, @IdRes vararg ignoreId: Int) { + when (this) { + is TextView -> { + if (!ignoreId.contains(id)) { + activateClick(background) + } + } + + is ViewGroup -> { + children.forEach { + it.autoActivateClick(background, *ignoreId) + } + } + } +} + +/** + * 循环 [ViewBinding] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun ViewBinding.activateAllTextViewClick( + background: Boolean = true, + @IdRes vararg ignoreId: Int +) { + root.autoActivateClick(background, *ignoreId) +} + +/** + * 循环 [Activity] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun Activity.activateAllTextViewClick(background: Boolean = true, @IdRes vararg ignoreId: Int) { + findViewById(R.id.content).children.first() + .autoActivateClick(background, *ignoreId) +} + +/** + * 循环 [Fragment] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun Fragment.activateAllTextViewClick(background: Boolean = true, @IdRes vararg ignoreId: Int) { + view.autoActivateClick(background, *ignoreId) +} +// + +// +/** + * [String] 转为 [Spannable], 以便进行plus操作 + */ +val String.span: SpannedString + get() = SpannedString(this) + +/** + * 扩展Spanned +, 保留样式 + * operator [Spannable] + [CharSequence] + * @return [Spannable] + */ +operator fun Spanned.plus(other: CharSequence): SpannableStringBuilder = + when (this) { + is SpannableStringBuilder -> append(other) + else -> SpannableStringBuilder(this).append(other) + } + +// \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt new file mode 100644 index 0000000..ea8f6fc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.annotation + +import androidx.annotation.IntDef + +@IntDef(value = [ConversionUnit.NOT_CONVERT, ConversionUnit.SP, ConversionUnit.DP]) +@Retention(AnnotationRetention.SOURCE) +annotation class ConversionUnit { + + companion object { + /** + * 不转换单位 + */ + const val NOT_CONVERT = 0 + + /** + * 转换为sp + */ + const val SP = 1 + + /** + * 转换为dp + */ + const val DP = 2 + } +} + + + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt new file mode 100644 index 0000000..ec3aca6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.annotation + +import android.graphics.Typeface +import androidx.annotation.IntDef + +/** + * copy form [Typeface.Style], it's is SOURCE annotation + */ +@IntDef(value = [Typeface.NORMAL, Typeface.BOLD, Typeface.ITALIC, Typeface.BOLD_ITALIC]) +@Retention(AnnotationRetention.SOURCE) +annotation class TextStyle \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java new file mode 100644 index 0000000..3a6c844 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.interfaces; + +import android.view.View; + +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableSpan; + +/** + * {@link SimpleClickableSpan} 点击回调 + *

+ * {@link SpanInternal#spanClickable} + */ +public interface OnSpanClickListener { + /** + * {@link SimpleClickableSpan}被点击时回调 + * + * @param v 点击的当前View + * @param matchText 点击时匹配上的文本 + */ + void onClick(View v, String matchText); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java new file mode 100644 index 0000000..29fdb99 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.interfaces; + +import com.remax.visualnovel.utils.spannablex.ReplaceRule; + +import kotlin.jvm.functions.Function1; +import kotlin.text.MatchResult; +import kotlin.text.Regex; + +/** + * 当 {@link ReplaceRule} 有匹配项时回调 + * 详细说明: {@link com.drake.spannable.SpanUtilsKt#replaceSpan(CharSequence, Regex, Function1)} + */ +public interface OnSpanReplacementMatch { + /** + * @param result 当前 @{@link Regex} 匹配到的结果 + */ + void onMatch(MatchResult result); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt new file mode 100644 index 0000000..8e83ca8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.style.LeadingMarginSpan +import androidx.annotation.IntRange +import androidx.annotation.Px + +class LeadingMarginSpan( + @IntRange(from = 1L) val firstLines: Int, + @Px val firstMargin: Int, + @Px val restMargin: Int, +) : LeadingMarginSpan.LeadingMarginSpan2 { + + override fun getLeadingMargin(first: Boolean): Int = if (first) firstMargin else restMargin + + override fun getLeadingMarginLineCount(): Int = firstLines + + override fun drawLeadingMargin( + c: Canvas?, p: Paint?, x: Int, dir: Int, top: Int, + baseline: Int, bottom: Int, text: CharSequence?, start: Int, end: Int, + first: Boolean, layout: Layout? + ) { + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt new file mode 100644 index 0000000..89eb4fc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import android.text.style.LineHeightSpan +import androidx.annotation.Px +import androidx.core.graphics.scale +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize + +class ParagraphBitmapSpan( + val bitmap: Bitmap, + private val drawableSize: DrawableSize?, + @Px val padding: Int +) : LeadingMarginSpan, LineHeightSpan { + private val bitmapHeight: Int + get() = drawableSize?.height ?: bitmap.height + + private val bitmapWidth: Int + get() = drawableSize?.width ?: bitmap.width + + override fun getLeadingMargin(first: Boolean): Int { + return bitmapWidth + padding + } + + override fun drawLeadingMargin( + c: Canvas, p: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout + ) { + val st = (text as Spanned).getSpanStart(this) + val lineTop = layout.getLineTop(layout.getLineForOffset(st)) + val scaleBitmap = drawableSize?.let { + bitmap.scale(bitmapWidth, bitmapHeight, true) + } ?: bitmap + + c.drawBitmap( + scaleBitmap, + (if (dir < 0) bitmapWidth - x else x).toFloat(), + lineTop.toFloat(), + p + ) + } + + override fun chooseHeight( + text: CharSequence, start: Int, end: Int, + istartv: Int, v: Int, + fm: FontMetricsInt + ) { + if (end == (text as Spanned).getSpanEnd(this)) { + val ht = bitmapHeight + var need = ht - (v + fm.descent - fm.ascent - istartv) + if (need > 0) { + fm.descent += need + } + need = ht - (v + fm.bottom - fm.top - istartv) + if (need > 0) { + fm.bottom += need + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt new file mode 100644 index 0000000..a09ef2c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.graphics.drawable.Drawable +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import android.text.style.LineHeightSpan +import androidx.annotation.Px +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize + +class ParagraphDrawableSpan( + val drawable: Drawable, + private val drawableSize: DrawableSize?, + @Px val padding: Int +) : LeadingMarginSpan, LineHeightSpan { + private val drawableHeight: Int + get() = drawableSize?.height ?: drawable.intrinsicHeight + + private val drawableWidth: Int + get() = drawableSize?.width ?: drawable.intrinsicWidth + + override fun getLeadingMargin(first: Boolean): Int { + return drawableWidth + padding + } + + override fun drawLeadingMargin( + c: Canvas, p: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout + ) { + val st = (text as Spanned).getSpanStart(this) + val lineTop = layout.getLineTop(layout.getLineForOffset(st)) + drawable.setBounds(x, lineTop, x + drawableWidth, lineTop + drawableHeight) + drawable.draw(c) + } + + override fun chooseHeight( + text: CharSequence, start: Int, end: Int, + istartv: Int, v: Int, + fm: FontMetricsInt + ) { + if (end == (text as Spanned).getSpanEnd(this)) { + val ht = drawableHeight + var need = ht - (v + fm.descent - fm.ascent - istartv) + if (need > 0) { + fm.descent += need + } + need = ht - (v + fm.bottom - fm.top - istartv) + if (need > 0) { + fm.bottom += need + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt new file mode 100644 index 0000000..7ddf581 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View +import androidx.annotation.ColorInt +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle + +typealias OnSimpleClickListener = (widget: View) -> Unit + +data class SimpleClickableConfig( + + /** + * 下划线 + */ + val underline: Boolean? = null, +) + +class SimpleClickableSpan( + @ColorInt private val color: Int? = null, + @ColorInt private val backgroundColor: Int? = null, + @TextStyle private val typeStyle: Int? = null, + private val config: SimpleClickableConfig? = null, + private val onClick: OnSimpleClickListener? = null +) : ClickableSpan() { + + constructor( + colorString: String?, + backgroundColorString: String?, + @TextStyle typeStyle: Int? = null, + config: SimpleClickableConfig? = null, + onClick: OnSimpleClickListener? = null + ) : this( + colorString?.let(Color::parseColor), + backgroundColorString?.let(Color::parseColor), + typeStyle, + config, + onClick + ) + + override fun updateDrawState(ds: TextPaint) { + color?.let(ds::setColor) + backgroundColor?.let { ds.bgColor = backgroundColor } + typeStyle?.let(Typeface::defaultFromStyle)?.let(ds::setTypeface) + + config?.run { + underline?.let(ds::setUnderlineText) + } + } + + override fun onClick(widget: View) { + onClick?.invoke(widget) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt new file mode 100644 index 0000000..4ef9003 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import androidx.annotation.ColorInt +import androidx.annotation.IntRange + +/** Copy @RequiresApi(Build.VERSION_CODES.P) Type from [android.text.style.BulletSpan] */ +class LegacyBulletSpan( + @ColorInt val color: Int, + @IntRange(from = 0) val bulletRadius: Int, + val gapWidth: Int +) : LeadingMarginSpan { + + override fun getLeadingMargin(first: Boolean): Int { + return 2 * bulletRadius + gapWidth + } + + override fun drawLeadingMargin( + canvas: Canvas, paint: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout? + ) { + if ((text as Spanned).getSpanStart(this) == start) { + val cacheStyle = paint.style + val cacheColor = paint.color + paint.color = color + paint.style = Paint.Style.FILL + val yPosition = (top + bottom) / 2f + val xPosition = (x + dir * bulletRadius).toFloat() + canvas.drawCircle(xPosition, yPosition, bulletRadius.toFloat(), paint) + paint.color = cacheColor + paint.style = cacheStyle + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt new file mode 100644 index 0000000..0a362f9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.LineBackgroundSpan +import androidx.annotation.ColorInt +import androidx.annotation.Px + +/** Copy @RequiresApi(Build.VERSION_CODES.Q) Type from [LineBackgroundSpan.Standard] */ +class LegacyLineBackgroundSpan(@ColorInt val color: Int) : LineBackgroundSpan { + + override fun drawBackground( + canvas: Canvas, paint: Paint, + @Px left: Int, @Px right: Int, + @Px top: Int, @Px baseline: Int, @Px bottom: Int, + text: CharSequence, start: Int, end: Int, + lineNumber: Int + ) { + val originColor = paint.color + paint.color = color + canvas.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), paint) + paint.color = originColor + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt new file mode 100644 index 0000000..eca76cf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Paint.FontMetricsInt +import android.text.style.LineHeightSpan +import androidx.annotation.IntRange +import androidx.annotation.Px +import kotlin.math.roundToInt + +/** Copy @RequiresApi(Build.VERSION_CODES.Q) Type from [LineHeightSpan.Standard] */ +class LegacyLineHeightSpan(@Px @IntRange(from = 1) val height: Int) : LineHeightSpan { + + override fun chooseHeight( + text: CharSequence, start: Int, end: Int, + spanstartv: Int, lineHeight: Int, + fm: FontMetricsInt + ) { + val originHeight = fm.descent - fm.ascent + if (originHeight <= 0) { + return + } + val ratio = height * 1.0f / originHeight + fm.descent = (fm.descent * ratio).roundToInt() + fm.ascent = fm.descent - height + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt new file mode 100644 index 0000000..81e770a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.style.LeadingMarginSpan +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import androidx.annotation.Px + +/** Copy @RequiresApi(Build.VERSION_CODES.P) Type from [android.text.style.QuoteSpan] */ +class LegacyQuoteSpan( + @ColorInt val color: Int, + @Px @IntRange(from = 0) val stripeWidth: Int, + @Px @IntRange(from = 0) val gapWidth: Int +) : LeadingMarginSpan { + + override fun getLeadingMargin(first: Boolean): Int { + return stripeWidth + gapWidth + } + + override fun drawLeadingMargin( + c: Canvas, p: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout + ) { + val cacheStyle = p.style + val cacheColor = p.color + + p.style = Paint.Style.FILL + p.color = color + c.drawRect( + x.toFloat(), + top.toFloat(), + (x + dir * stripeWidth).toFloat(), + bottom.toFloat(), + p + ) + + p.style = cacheStyle + p.color = cacheColor + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt new file mode 100644 index 0000000..f29527f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.utils + +import android.graphics.drawable.Drawable +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.IntRange +import androidx.annotation.Keep +import com.drake.spannable.span.CenterImageSpan + +/** + * [CenterImageSpan] 的大小配置辅助类 + */ +@Keep +data class DrawableSize( + @IntRange(from = 0L) val width: Int, + @IntRange(from = 0L) val height: Int = width +) + +/** + * 快速构建 [DrawableSize] + */ +val Int.drawableSize: DrawableSize + get() = DrawableSize(this, this) + + +/** + * 设置[Drawable] 大小 + */ +fun Drawable.drawableSize(width: Int, height: Int = width): Drawable = apply { + setBounds(0, 0, width, height) +} + +/** + * 设置[Drawable] 大小为[TextView] [EditText] 的字体大小 + * @param view 参考文字大小的textSize view + */ +fun Drawable.configTextViewSize(view: T?): Drawable = apply { + view?.textSizeInt?.let { size -> + drawableSize(size, size) + } ?: drawableSize(intrinsicWidth, intrinsicHeight) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt new file mode 100644 index 0000000..fe63676 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.utils + +import android.content.res.Resources +import android.graphics.Color +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.graphics.toColorInt +import kotlin.math.roundToInt + +/** + * ColorString 2 [ColorInt] + * error default is [Color.RED] + */ +val String.color: Int + get() = try { + toColorInt() + } catch (e: Exception) { + Color.RED + } + +/** + * @receiver dp 2 px + */ +val Int.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).roundToInt() + +/** + * @receiver dp 2 px + */ +val Float.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).roundToInt() + +/** + * @receiver sp 2 px + */ +val Int.sp: Int + get() = (this * Resources.getSystem().displayMetrics.scaledDensity + 0.5f).roundToInt() + +/** + * 获取Int型[TextView.getTextSize] + */ +val TextView.textSizeInt: Int + get() = textSize.roundToInt() diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt new file mode 100644 index 0000000..6f2df2c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt @@ -0,0 +1,63 @@ +package com.remax.visualnovel.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.StringRes +import androidx.core.content.withStyledAttributes +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetPriceViewBinding +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.dylanc.viewbinding.nonreflection.inflate + +/** + * Created by HJW on 2023/8/2 + */ +@SuppressLint("SetTextI18n") +class PriceView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + LinearLayout(context, attrs, defStyleAttr) { + + private var binding: WidgetPriceViewBinding? = null + + init { + binding = inflate(WidgetPriceViewBinding::inflate) + context.withStyledAttributes(attrs, R.styleable.PriceView) { + val iconSize = getDimensionPixelOffset(R.styleable.PriceView_priceIconSize, 16.dp) + val priceIconPadding = getDimensionPixelOffset(R.styleable.PriceView_priceIconPadding, 4.dp) + + binding?.priceIcon?.run { + setMargin(marginEnd = priceIconPadding) + setSize(iconSize, iconSize) + } + + + getString(R.styleable.PriceView_priceTextToken)?.let { txtToken -> + binding?.priceTv?.changeTextFont { + textUITextToken = txtToken + } + } + + val content = getString(R.styleable.PriceView_priceText) + binding?.priceTv?.text = content + } + } + + fun getContentView() = binding?.priceTv + + fun setSizeType(@StringRes txtToken: Int, iconSize: Int) { + binding?.run { + priceTv.changeTextFont { + textUITextToken = context.getString(txtToken) + } + priceIcon.setSize(iconSize, iconSize) + } + } + + fun setPrice(content: String?) { + binding?.priceTv?.text = content + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt new file mode 100644 index 0000000..752f589 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt @@ -0,0 +1,333 @@ +package com.remax.visualnovel.widget.custom + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.text.TextUtils +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.remax.visualnovel.R +import kotlin.math.max +import kotlin.math.min + +class TagFlowLayout2 @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ViewGroup(context, attrs, defStyleAttr) { + + // 属性变量 + private var horizontalSpacing = 20f.dpToPx() + private var verticalSpacing = 12f.dpToPx() + private var textSize = 14f.spToPx() + private var textColor = Color.WHITE + private var tagBackground: Drawable? = null + private var maxLines = Int.MAX_VALUE + private var expandIndicator: Drawable? = null + private var collapseIndicator: Drawable? = null + private var eachLineMaxTagNum = 2 //一行最大标签宽度数 + + // 状态变量 + private var isExpanded = false + private var actualLineCount = 0 + private var showExpandButton = false + private var eachLineAvailableWidth = 0 // 可用宽度 + + // 数据 + private val tagItems = mutableListOf() + private val tagViews = mutableListOf() + private lateinit var expandButton: TextView + + // 监听器 + private var onTagClickListener: ((TagItem) -> Unit)? = null + private var onExpandStateChangeListener: ((Boolean) -> Unit)? = null + + init { + initAttributes(attrs) + initExpandButton() + } + + private fun initAttributes(attrs: AttributeSet?) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TagFlowLayout2) + + horizontalSpacing = typedArray.getDimension( + R.styleable.TagFlowLayout2_tag_horizontal_spacing, horizontalSpacing + ) + verticalSpacing = typedArray.getDimension( + R.styleable.TagFlowLayout2_tag_vertical_spacing, verticalSpacing + ) + textSize = typedArray.getDimension( + R.styleable.TagFlowLayout2_tag_text_size, textSize + ) + textColor = typedArray.getColor( + R.styleable.TagFlowLayout2_tag_text_color, textColor + ) + tagBackground = typedArray.getDrawable(R.styleable.TagFlowLayout2_tag_background) + maxLines = typedArray.getInt(R.styleable.TagFlowLayout2_tag_max_lines, Int.MAX_VALUE) + expandIndicator = typedArray.getDrawable(R.styleable.TagFlowLayout2_expand_indicator_drawable) + collapseIndicator = typedArray.getDrawable(R.styleable.TagFlowLayout2_collapse_indicator_drawable) + eachLineMaxTagNum = typedArray.getInt(R.styleable.TagFlowLayout2_each_line_max_num, 2) + typedArray.recycle() + + + if (tagBackground == null) { + tagBackground = createDefaultBackground() + } + if (expandIndicator == null) { + expandIndicator = ContextCompat.getDrawable(context, R.mipmap.tag_flow_expand) + } + if (collapseIndicator == null) { + collapseIndicator = ContextCompat.getDrawable(context, R.mipmap.tag_flow_shrink) + } + } + + private fun createDefaultBackground(): Drawable { + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = 16f.dpToPx() + gradientDrawable.setColor(Color.parseColor("#8A8A8E")) + return gradientDrawable + } + + private fun initExpandButton() { + expandButton = TextView(context).apply { + text = "展开" + setCompoundDrawablesWithIntrinsicBounds(null, null, expandIndicator, null) + compoundDrawablePadding = 4.dpToPx() + setTextColor(Color.parseColor("#8A8A8E")) + textSize = 12f + setPadding(12.dpToPx(), 6.dpToPx(), 8.dpToPx(), 6.dpToPx()) + background = createExpandButtonBackground() + + setOnClickListener { + toggleExpandState() + } + } + addView(expandButton) + } + + private fun createExpandButtonBackground(): Drawable { + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = 16f.dpToPx() + gradientDrawable.setColor(Color.parseColor("#E5E5EA")) + gradientDrawable.setStroke(1.dpToPx(), Color.parseColor("#C6C6C8")) + return gradientDrawable + } + + // 设置标签数据 + fun setTags(tags: List) { + tagItems.clear() + tagViews.forEach { removeView(it) } + tagViews.clear() + + tagItems.addAll(tags) + + tags.forEach { tag -> + val textView = createTagView(tag) + tagViews.add(textView) + addView(textView) + } + + requestLayout() + } + + private fun createTagView(tag: TagItem): TextView { + return TextView(context).apply { + text = tag.text + setTextColor(textColor) + textSize = textSize / resources.displayMetrics.scaledDensity + setPadding(16.dpToPx(), 8.dpToPx(), 16.dpToPx(), 8.dpToPx()) + setBackgroundResource(R.drawable.tag_flow_item_bg) + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + maxLines = 1 + includeFontPadding = false + + setOnClickListener { + onTagClickListener?.invoke(tag) + } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + eachLineAvailableWidth = width - paddingLeft - paddingRight + + val oneTagMaxWidth = (eachLineAvailableWidth - (eachLineMaxTagNum-1) * horizontalSpacing) / eachLineMaxTagNum + var totalNeedHeight = 0 + if (tagViews.isEmpty()) { + setMeasuredDimension(width, totalNeedHeight) + return + } + + // 为所有标签应用最大宽度限制 + tagViews.forEach { subView -> + val maxWidthSpec = MeasureSpec.makeMeasureSpec(oneTagMaxWidth.toInt(), MeasureSpec.AT_MOST) + val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + subView.measure(maxWidthSpec, heightSpec) + } + + + var curLineTotalWidth = 0 + var curLineTotalHeight = 0 + var lineCount = 0 + val maxDisplayLines = if (isExpanded) Int.MAX_VALUE else maxLines + + tagViews.forEach { view -> + val childWidth = view.measuredWidth + val childHeight = view.measuredHeight + + // 检查是否需要换行(考虑水平间距) + val curLineNeedTotalWidth: Int = if (curLineTotalWidth == 0) childWidth + else curLineTotalWidth + horizontalSpacing.toInt() + childWidth + + if (curLineNeedTotalWidth > eachLineAvailableWidth) { + // 换行处理 + lineCount++ + if (lineCount >= maxDisplayLines) { + return@forEach + } + + totalNeedHeight += curLineTotalHeight + (if (lineCount >= 1) verticalSpacing.toInt() else 0) + curLineTotalWidth = childWidth + curLineTotalHeight = childHeight + } else { + curLineTotalWidth = curLineNeedTotalWidth + curLineTotalHeight = max(curLineTotalHeight, childHeight) + } + } + + // 添加最后一行高度 + if (lineCount < maxDisplayLines && tagViews.isNotEmpty()) { + totalNeedHeight += curLineTotalHeight + } + + // 添加padding + totalNeedHeight += paddingTop + paddingBottom + + actualLineCount = lineCount + 1 + showExpandButton = actualLineCount > maxLines && !isExpanded + + // 测量展开按钮 + if (showExpandButton) { + val buttonWidthSpec = MeasureSpec.makeMeasureSpec(eachLineAvailableWidth, MeasureSpec.AT_MOST) + val buttonHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + expandButton.measure(buttonWidthSpec, buttonHeightSpec) + } + + setMeasuredDimension(width, totalNeedHeight) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + if (tagViews.isEmpty()) return + + val width = r - l + val maxTagWidth = (eachLineAvailableWidth * eachLineMaxTagNum).toInt() + + var currentLeft = paddingLeft + var currentTop = paddingTop + var currentLineHeight = 0 + var lineCount = 0 + val maxDisplayLines = if (isExpanded) Int.MAX_VALUE else maxLines + + // 布局可见的标签 + for (i in tagViews.indices) { + val view = tagViews[i] + val childWidth = min(view.measuredWidth, maxTagWidth) + val childHeight = view.measuredHeight + + // 检查是否需要换行 + if (currentLeft + childWidth > width - paddingRight) { + lineCount++ + if (lineCount >= maxDisplayLines) { + view.visibility = GONE + continue + } + + currentLeft = paddingLeft + currentTop += currentLineHeight + verticalSpacing.toInt() + currentLineHeight = 0 + } + + view.visibility = VISIBLE + view.layout(currentLeft, currentTop, currentLeft + childWidth, currentTop + childHeight) + + currentLeft += childWidth + horizontalSpacing.toInt() + currentLineHeight = max(currentLineHeight, childHeight) + } + + // 布局展开按钮 + layoutExpandButton(width, currentTop, currentLineHeight) + } + + private fun layoutExpandButton(parentWidth: Int, currentTop: Int, currentLineHeight: Int) { + if (showExpandButton) { + expandButton.visibility = VISIBLE + val buttonWidth = expandButton.measuredWidth + val buttonHeight = expandButton.measuredHeight + + // 计算按钮位置(在当前行右侧) + val buttonLeft = parentWidth - paddingRight - buttonWidth + val buttonTop = currentTop + (currentLineHeight - buttonHeight) / 2 + + expandButton.layout( + buttonLeft, + buttonTop, + buttonLeft + buttonWidth, + buttonTop + buttonHeight + ) + } else { + expandButton.visibility = GONE + } + } + + private fun toggleExpandState() { + isExpanded = !isExpanded + updateExpandButton() + requestLayout() + onExpandStateChangeListener?.invoke(isExpanded) + } + + private fun updateExpandButton() { + val indicator = if (isExpanded) collapseIndicator else expandIndicator + expandButton.setCompoundDrawablesWithIntrinsicBounds(null, null, indicator, null) + expandButton.text = if (isExpanded) "收起" else "展开" + } + + // 公共方法 + fun setOnTagClickListener(listener: (TagItem) -> Unit) { + onTagClickListener = listener + } + + fun setOnExpandStateChangeListener(listener: (Boolean) -> Unit) { + onExpandStateChangeListener = listener + } + + fun expand() { + if (!isExpanded) { + toggleExpandState() + } + } + + fun collapse() { + if (isExpanded) { + toggleExpandState() + } + } + + fun isExpanded(): Boolean = isExpanded + + // 设置最大标签数 + fun setMaxTagsNumEachLine(maxTagNumForEachLine: Int) { + require(maxTagNumForEachLine >= 1 && maxTagNumForEachLine <= 10) { "Ratio must be between 1 and 10" } + eachLineMaxTagNum = maxTagNumForEachLine + requestLayout() + } + + // 扩展函数 + private fun Float.dpToPx(): Float = this * resources.displayMetrics.density + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt() + private fun Float.spToPx(): Float = this * resources.displayMetrics.scaledDensity +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt new file mode 100644 index 0000000..6afff85 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt @@ -0,0 +1,7 @@ +package com.remax.visualnovel.widget.custom + +data class TagItem( + val id: String, + val text: String, + var isSelected: Boolean = false +) \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt new file mode 100644 index 0000000..c558236 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt @@ -0,0 +1,444 @@ +package com.remax.visualnovel.widget.dialoglib + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape +import android.os.Bundle +import android.util.SparseArray +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.ImageView +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken + +/** + * Created by HJW on 2021/10/20 + */ +open class LBindingDialog(private val context: Context, + private val inflate: (LayoutInflater) -> VB, + themeResId: Int = R.style.LDialog) : + Dialog(context, themeResId), LifecycleEventObserver { + private val views = SparseArray() + private var width = 0 + private var height = 0 + private var bgRadius = 0 //背景圆角 + + private var leftTopRadius = 0 + private var rightTopRadius = 0 + private var leftBottomRadius = 0 + private var rightBottomRadius = 0 + private var bgColor = Color.TRANSPARENT //背景颜色 + + lateinit var binding: VB + var currEvent: Lifecycle.Event? = null + + companion object { + + fun getRoundRectDrawable(radius: Int, color: Int): ShapeDrawable { + return getRoundRectDrawable(radius, radius, radius, radius, color) + } + + fun getRoundRectDrawable(leftTop: Int, rightTop: Int, rightBottom: Int, leftBottom: Int, color: Int): ShapeDrawable { + //左上、右上、右下、左下的圆角半径 + val radius = floatArrayOf( + leftTop.toFloat(), + leftTop.toFloat(), + rightTop.toFloat(), + rightTop.toFloat(), + rightBottom.toFloat(), + rightBottom.toFloat(), + leftBottom.toFloat(), + leftBottom.toFloat() + ) + val drawable = ShapeDrawable().apply { + shape = RoundRectShape(radius, null, null) + paint.color = color + } + return drawable + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = inflate(layoutInflater) + setContentView(binding.root) + init() + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + currEvent = event + if (Lifecycle.Event.ON_DESTROY == event && isShowing) { + dismiss() + } + } + + fun init() { + if (context is ComponentActivity) { + context.lifecycle.addObserver(this) + } + setCanceledOnTouchOutside(true) + window?.setBackgroundDrawableResource(R.color.transparent) + width = (ScreenUtils.getScreenWidth() * 0.8).toInt() + height = WindowManager.LayoutParams.WRAP_CONTENT + setWidthHeight() + window?.setWindowAnimations(R.style.dialog_alpha) + } + + fun with(): LBindingDialog { + create() + setBgColorToken(R.string.color_surface_base_normal) + return this + } + + fun setAnimationsStyle(style: Int): LBindingDialog { + window?.setWindowAnimations(style) + return this + } + + /** + * 设置位置 + */ + fun setGravity(gravity: Int, offX: Int, offY: Int): LBindingDialog { + setGravity(gravity) + val layoutParams = window?.attributes + layoutParams?.x = offX + layoutParams?.y = offY + window?.attributes = layoutParams + return this + } + + fun setGravity(gravity: Int): LBindingDialog { + window?.setGravity(gravity) + return this + } + + + fun setBottom(): LBindingDialog { + setGravity(Gravity.BOTTOM) + setAnimationsStyle(R.style.dialog_translate) + setWidthRatio(1.0) + setDBgRadius(24, 24, 0, 0) + return this + } + + fun setCenter(cancelable: Boolean = true): LBindingDialog { + setGravity(Gravity.CENTER) + setWidthRatio(0.8) + setBgRadius(16) + setAnimationsStyle(R.style.dialog_alpha) + setCanceledOnTouchOutside(cancelable) + setCancelable(cancelable) + return this + } + + override fun show() { + if (!isShowing) { + super.show() + } + } + + fun thisShow(): DialogInterface { + this.show() + return this + } + + /** + * 遮罩透明度 + * + * @param value 0-1f + */ + fun setMaskValue(value: Float): LBindingDialog { + window?.setDimAmount(value) + return this + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置背景>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + open fun setBg(): LBindingDialog { + if (leftTopRadius != 0 || rightTopRadius != 0 || rightBottomRadius != 0 || leftBottomRadius != 0) { + window?.setBackgroundDrawable(getRoundRectDrawable(leftTopRadius, rightTopRadius, rightBottomRadius, leftBottomRadius, bgColor)) + } else { + window?.setBackgroundDrawable(getRoundRectDrawable(bgRadius, bgColor)) + } + return this + } + + /** + * 设置背景颜色 + */ + fun setBgColor(@ColorInt color: Int): LBindingDialog { + bgColor = color + return setBg() + } + + fun setBgColorToken(@StringRes colorToken: Int): LBindingDialog { + bgColor = context.handleUIToken(colorToken)?.color ?: 0 + return setBg() + } + + fun setBgColorRes(colorRes: Int): LBindingDialog { + bgColor = ContextCompat.getColor(context, colorRes) + return setBg() + } + + /** + * 设置背景圆角 + */ + fun setBgRadius(bgRadius: Int): LBindingDialog { + this.bgRadius = bgRadius.dp + return setBg() + } + + /** + * 设置背景不同圆角 + */ + fun setDBgRadius(leftTopRadius: Int, rightTopRadius: Int, rightBottomRadius: Int, leftBottomRadius: Int): LBindingDialog { + this.leftTopRadius = leftTopRadius.dp + this.rightTopRadius = rightTopRadius.dp + this.rightBottomRadius = rightBottomRadius.dp + this.leftBottomRadius = leftBottomRadius.dp + return setBg() + } + + /** + * 设置背景圆角 + */ + fun setBgRadiusPX(bgRadius: Int): LBindingDialog { + this.bgRadius = bgRadius + return setBg() + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置宽高>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + /** + * 设置宽高 + */ + open fun setWidthHeight(): LBindingDialog { + val dialogWindow = window + val lp = dialogWindow?.attributes + lp?.width = width + lp?.height = height + dialogWindow?.attributes = lp + return this + } + + fun setWidth(width: Int): LBindingDialog { + this.width = width.dp + return setWidthHeight() + } + + fun setWidthPX(width: Int): LBindingDialog { + this.width = width + return setWidthHeight() + } + + fun setHeight(height: Int): LBindingDialog { + this.height = height.dp + return setWidthHeight() + } + + fun setHeightPX(height: Int): LBindingDialog { + this.height = height + return setWidthHeight() + } + + /** + * 设置宽占屏幕的比例 + */ + fun setWidthRatio(widthRatio: Double): LBindingDialog { + width = (ScreenUtils.getScreenWidth() * widthRatio).toInt() + setWidthHeight() + return this + } + + /** + * 设置高占屏幕的比例 + */ + fun setHeightRatio(heightRatio: Double): LBindingDialog { + height = (ScreenUtils.getHeightRealPixels() * heightRatio).toInt() + setWidthHeight() + return this + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置监听>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + /** + * 设置监听 + */ + open fun setOnClickListener(onClickListener: DialogOnClickListener, vararg viewIds: Int): LBindingDialog { + val lDialog: LBindingDialog = this + for (element in viewIds) { + getView(element).setOnClickListener { v -> onClickListener.onClick(v, lDialog) } + } + return this + } + + interface DialogOnClickListener { + fun onClick(v: View, lDialog: LBindingDialog<*>) + } + + /** + * 设置 关闭dialog的按钮 + */ + fun setCancelBtn(viewId: Int): LBindingDialog { + getView(viewId)?.setOnClickListener(View.OnClickListener { dismiss() }) + return this + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置常见属性>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + @Suppress("UNCHECKED_CAST") + fun getView(@IdRes viewId: Int): T { + var view = views[viewId] + if (view == null) { + view = binding.root.findViewById(viewId) + views[viewId] = view + } + return view as T + } + + /** + * Will set the text of a TextView. + * + * @param viewId The view id. + * @param value The text to put in the text view. + * @return The BaseViewHolder for chaining. + */ + fun setText(@IdRes viewId: Int, value: CharSequence): LBindingDialog { + val view = getView(viewId) + view.text = value + return this + } + + fun setText(@IdRes viewId: Int, @StringRes strId: Int): LBindingDialog { + val view = getView(viewId) + view.setText(strId) + return this + } + + /** + * Will set the image of an ImageView from a resource id. + * + * @param viewId The view id. + * @param imageResId The image resource id. + * @return The BaseViewHolder for chaining. + */ + fun setImageResource(@IdRes viewId: Int, @DrawableRes imageResId: Int): LBindingDialog { + val view = getView(viewId) + view.setImageResource(imageResId) + return this + } + + /** + * Will set background color of a view. + * + * @param viewId The view id. + * @param color A color, not a resource id. + * @return The BaseViewHolder for chaining. + */ + fun setBackgroundColor(@IdRes viewId: Int, @ColorInt color: Int): LBindingDialog { + val view = getView(viewId) + view.setBackgroundColor(color) + return this + } + + /** + * Will set background of a view. + * + * @param viewId The view id. + * @param backgroundRes A resource to use as a background. + * @return The BaseViewHolder for chaining. + */ + fun setBackgroundRes(@IdRes viewId: Int, @DrawableRes backgroundRes: Int): LBindingDialog { + val view = getView(viewId) + view.setBackgroundResource(backgroundRes) + return this + } + + /** + * Will set text color of a TextView. + * + * @param viewId The view id. + * @param textColor The text color (not a resource id). + * @return The BaseViewHolder for chaining. + */ + fun setTextColor(@IdRes viewId: Int, @ColorInt textColor: Int): LBindingDialog { + val view = getView(viewId) + view.setTextColor(textColor) + return this + } + + + /** + * Will set the image of an ImageView from a drawable. + * + * @param viewId The view id. + * @param drawable The image drawable. + * @return The BaseViewHolder for chaining. + */ + fun setImageDrawable(@IdRes viewId: Int, drawable: Drawable): LBindingDialog { + val view = getView(viewId) + view.setImageDrawable(drawable) + return this + } + + /** + * Add an action to set the image of an image view. Can be called multiple times. + */ + fun setImageBitmap(@IdRes viewId: Int, bitmap: Bitmap): LBindingDialog { + val view = getView(viewId) + view.setImageBitmap(bitmap) + return this + } + + /** + * Add an action to set the alpha of a view. Can be called multiple times. + * Alpha between 0-1. + */ + fun setAlpha(@IdRes viewId: Int, value: Float): LBindingDialog { + getView(viewId).alpha = value + return this + } + + /** + * Set a view visibility to VISIBLE (true) or GONE (false). + * + * @param viewId The view id. + * @param visible True for VISIBLE, false for GONE. + * @return The BaseViewHolder for chaining. + */ + fun setGone(@IdRes viewId: Int, visible: Boolean): LBindingDialog { + val view = getView(viewId) + view.visibility = if (visible) View.VISIBLE else View.GONE + return this + } + + /** + * Set a view visibility to VISIBLE (true) or INVISIBLE (false). + * + * @param viewId The view id. + * @param visible True for VISIBLE, false for INVISIBLE. + * @return The BaseViewHolder for chaining. + */ + fun setVisible(@IdRes viewId: Int, visible: Boolean): LBindingDialog { + val view = getView(viewId) + view.visibility = if (visible) View.VISIBLE else View.INVISIBLE + return this + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java new file mode 100644 index 0000000..c7d6ed7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java @@ -0,0 +1,77 @@ +package com.remax.visualnovel.widget.dialoglib; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.view.Display; +import android.view.WindowManager; +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.remax.visualnovel.utils.StatusBarUtils; + +/** + * 获取屏幕信息 工具类 + */ +public class ScreenUtils { + /** + * 可用距离: 全屏高度- 状态栏- 导航栏 + * + * @return 可用高度 + */ + public static int getHeightRealPixels() { + return getScreenHeight() - StatusBarUtils.INSTANCE.getStatusBarHeight() - StatusBarUtils.INSTANCE.getNavBarHeight(false); + } + + private static Display getDisplay(Context context) { + WindowManager wm; + if (context instanceof Activity) { + Activity activity = (Activity) context; + wm = activity.getWindowManager(); + } else { + wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + if (wm != null) { + return wm.getDefaultDisplay(); + } + return null; + } + + /** + * Return the width of screen, in pixel. + * + * @return the width of screen, in pixel + */ + public static int getScreenWidth() { + WindowManager wm = (WindowManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.WINDOW_SERVICE); + if (wm == null) return -1; + Point point = new Point(); + wm.getDefaultDisplay().getRealSize(point); + return point.x; + } + + /** + * 获得整个屏幕的高度,包括状态栏和导航栏 + * + * @return the height of screen, in pixel + */ + public static int getScreenHeight() { + WindowManager wm = (WindowManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.WINDOW_SERVICE); + if (wm == null) return -1; + Point point = new Point(); + wm.getDefaultDisplay().getRealSize(point); + return point.y; + } + + /** + * 判断当前设备是手机还是平板,代码来自 Google I/O App for Android + * + * @param context + * @return 平板返回 True,手机返回 False + */ + public static boolean isPad(Context context) { + return (context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java new file mode 100644 index 0000000..ff21c25 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java @@ -0,0 +1,128 @@ +package com.remax.visualnovel.widget.glidetransformation; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.RectF; +import androidx.annotation.NonNull; +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import java.security.MessageDigest; +import jp.wasabeef.glide.transformations.BitmapTransformation; +import jp.wasabeef.glide.transformations.BuildConfig; + +/** + * Created by HJW on 2023/7/25 + */ +public class CropRectTransformation extends BitmapTransformation { + + private static final int VERSION = 1; + private static final String ID = "com.remax.visualnovel.widget.glidetransformation.CropTransformation." + VERSION; + + public enum CropXType { + TOP, + CENTER, + BOTTOM + } + + public enum CropYType { + LEFT, + CENTER, + RIGHT + } + + private int width; + private int height; + + private final CropXType cropXType; + private final CropYType cropYType; + + public CropRectTransformation(int width, int height) { + this(width, height, CropXType.CENTER, CropYType.CENTER); + } + + public CropRectTransformation(int width, int height, CropXType cropXType, CropYType cropYType) { + this.width = width; + this.height = height; + this.cropXType = cropXType; + this.cropYType = cropYType; + } + + @Override + protected Bitmap transform(@NonNull Context context, @NonNull BitmapPool pool, + @NonNull Bitmap toTransform, int outWidth, int outHeight) { + + width = width == 0 ? toTransform.getWidth() : width; + height = height == 0 ? toTransform.getHeight() : height; + + Bitmap.Config config = + toTransform.getConfig() != null ? toTransform.getConfig() : Bitmap.Config.ARGB_8888; + Bitmap bitmap = pool.get(width, height, config); + + bitmap.setHasAlpha(true); + + float scaleX = (float) width / toTransform.getWidth(); + float scaleY = (float) height / toTransform.getHeight(); + float scale = Math.max(scaleX, scaleY); + + float scaledWidth = scale * toTransform.getWidth(); + float scaledHeight = scale * toTransform.getHeight(); + float left = getLeft(scaledWidth); + float top = getTop(scaledHeight); + RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight); + + bitmap.setDensity(toTransform.getDensity()); + + Canvas canvas = new Canvas(bitmap); + canvas.drawBitmap(toTransform, null, targetRect, null); + + return bitmap; + } + + private float getTop(float scaledHeight) { + switch (cropXType) { + case CENTER: + return (height - scaledHeight) / 2; + case BOTTOM: + return height - scaledHeight; + default: + return 0; + } + } + + private float getLeft(float scaledWidth) { + switch (cropYType) { + case CENTER: + return (width - scaledWidth) / 2; + case RIGHT: + return width - scaledWidth; + default: + return 0; + } + } + + + @NonNull + @Override + public String toString() { + return "CropTransformation(width=" + width + ", height=" + height + ", cropXType=" + cropXType + ", cropYType=" + cropYType + ")"; + } + + @Override + public boolean equals(Object o) { + return o instanceof CropRectTransformation && + ((CropRectTransformation) o).width == width && + ((CropRectTransformation) o).height == height && + ((CropRectTransformation) o).cropXType == cropXType && + ((CropRectTransformation) o).cropYType == cropYType; + } + + @Override + public int hashCode() { + return ID.hashCode() + width * 100000 + height * 1000 + cropXType.ordinal() * 10 + cropYType.ordinal() * 10; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update((ID + width + height + cropXType + cropYType).getBytes(CHARSET)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java new file mode 100644 index 0000000..feeff38 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java @@ -0,0 +1,365 @@ +package com.remax.visualnovel.widget.imagepicker; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; + +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.activity.preview.MultiImagePreviewActivity; +import com.remax.visualnovel.widget.imagepicker.activity.singlecrop.SingleCropActivity; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.builder.CropPickerBuilder; +import com.remax.visualnovel.widget.imagepicker.builder.MultiPickerBuilder; +import com.remax.visualnovel.widget.imagepicker.data.MediaItemsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.MediaSetsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; +import com.remax.visualnovel.widget.imagepicker.helper.CameraCompat; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PPermissionUtils; + +import java.util.ArrayList; +import java.util.Set; + +/** + * Description: 图片加载启动类 + *

+ * Author: peixing.yang + * Date: 2019/2/28 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class ImagePicker { + public static String DEFAULT_FILE_NAME = "imagePicker"; + //选择返回的key + public static final String INTENT_KEY_PICKER_RESULT = "pickerResult"; + //选择返回code + public static final int REQ_PICKER_RESULT_CODE = 1433; + //拍照返回码、拍照权限码 + public static final int REQ_CAMERA = 1431; + //存储权限码 + public static final int REQ_STORAGE = 1432; + + /** + * 是否选中原图 + */ + public static boolean isOriginalImage = false; + + private static int themeColor = Color.RED; + + private static boolean previewWithHighQuality = false; + + /** + * @param previewWithHighQuality 预览是否极致高清,true会导致放大后滑动卡顿,false在加载超过3K图片时,放大后部分像素丢失 + */ + public static void setPreviewWithHighQuality(boolean previewWithHighQuality) { + ImagePicker.previewWithHighQuality = previewWithHighQuality; + } + + public static boolean isPreviewWithHighQuality() { + return previewWithHighQuality; + } + + /** + * 小红书样式剪裁activity形式 + * + * @param presenter 数据交互类 + */ + public static CropPickerBuilder withCrop(IPickerPresenter presenter) { + return new CropPickerBuilder(presenter); + } + + /** + * 微信样式多选 + * + * @param presenter 选择器UI提供者 + * @return 微信样式多选 + */ + public static MultiPickerBuilder withMulti(IPickerPresenter presenter) { + return new MultiPickerBuilder(presenter); + } + + /** + * 兼容安卓10拍照.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity 调用拍照的页面 + * @param imageName 图片名称 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 拍照回调 + */ + public static void takePhoto(Activity activity, + String imageName, + boolean isCopyInDCIM, + OnImagePickCompleteListener listener) { + if (imageName == null || imageName.length() == 0) { + imageName = "Img_" + System.currentTimeMillis(); + } + CameraCompat.takePhoto(activity, imageName, isCopyInDCIM, listener); + } + + /** + * 兼容安卓10拍摄视频.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity activity + * @param videoName 视频名称 + * @param maxDuration 视频最大时长 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 视频回调 + */ + public static void takeVideo(Activity activity, + String videoName, + long maxDuration, + boolean isCopyInDCIM, + OnImagePickCompleteListener listener) { + if (videoName == null || videoName.length() == 0) { + videoName = "Video_" + System.currentTimeMillis(); + } + CameraCompat.takeVideo(activity, videoName, maxDuration, isCopyInDCIM, listener); + } + + + /** + * 直接调用拍照并剪裁 + * + * @param activity 调用activity + * @param presenter 选择器样式类,主要负责返回UIConfig + * @param cropConfig 剪裁配置 + * @param listener 剪裁回调 + */ + public static void takePhotoAndCrop(final Activity activity, + final IPickerPresenter presenter, + final CropConfig cropConfig, + @NonNull final OnImagePickCompleteListener listener) { + if (presenter == null) { + PickerErrorExecutor.executeError(activity, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + if (cropConfig == null) { + PickerErrorExecutor.executeError(activity, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return; + } + takePhoto(activity, null, false, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + if (items != null && items.size() > 0) { + SingleCropActivity.intentCrop(activity, presenter, cropConfig, items.get(0), listener); + } + } + }); + } + + /** + * 直接调用拍照并剪裁 + * + * @param activity 调用activity + * @param presenter 选择器样式类,主要负责返回UIConfig + * @param cropConfig 剪裁配置 + * @param cropImagePath 需要剪裁的图片路径,可以是uri路径 + * @param listener 剪裁回调 + */ + public static void crop(final Activity activity, final IPickerPresenter presenter, + final CropConfig cropConfig, String cropImagePath, + final OnImagePickCompleteListener listener) { + if (presenter == null || cropConfig == null || listener == null) { + PickerErrorExecutor.executeError(activity, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + SingleCropActivity.intentCrop(activity, presenter, cropConfig, cropImagePath, listener); + } + + /** + * 直接调用拍照并剪裁 + * + * @param activity 调用activity + * @param presenter 选择器样式类,主要负责返回UIConfig + * @param cropConfig 剪裁配置 + * @param imageItem 需要剪裁的图片信息 + * @param listener 剪裁回调 + */ + public static void crop(final Activity activity, final IPickerPresenter presenter, + final CropConfig cropConfig, ImageItem imageItem, + final OnImagePickCompleteListener listener) { + if (presenter == null || cropConfig == null || listener == null) { + PickerErrorExecutor.executeError(activity, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + SingleCropActivity.intentCrop(activity, presenter, cropConfig, imageItem, listener); + } + + /** + * 图片预览 + * + * @param context 上下文 + * @param imageList 预览的图片数组 + * @param pos 默认位置 + * @param listener 编辑回调 + * @param String or ImageItem + */ + public static void preview(Activity context, final IPickerPresenter presenter, ArrayList imageList, + int pos, final OnImagePickCompleteListener listener) { + if (imageList == null || imageList.size() == 0) { + return; + } + MultiSelectConfig selectConfig = new MultiSelectConfig(); + selectConfig.setMaxCount(imageList.size()); + MultiImagePreviewActivity.intent(context, null, transitArray(context, imageList), + selectConfig, presenter, pos, (imageItems, isCancel) -> { + if (listener != null) { + if (isCancel && listener instanceof OnImagePickCompleteListener2) { + ((OnImagePickCompleteListener2) listener).onPickFailed(PickerError.CANCEL); + } else { + listener.onImagePickComplete(imageItems); + } + } + }); + } + + /** + * @param imageList 需要转化的list + * @param ImageItem or String + * @return 转化后可识别的item列表 + */ + public static ArrayList transitArray(Activity activity, ArrayList imageList) { + ArrayList items = new ArrayList<>(); + for (T t : imageList) { + if (t instanceof String) { + ImageItem imageItem = ImageItem.withPath(activity, (String) t); + items.add(imageItem); + } else if (t instanceof ImageItem) { + items.add((ImageItem) t); + } else if (t instanceof Uri) { + Uri uri = (Uri) t; + ImageItem imageItem = new ImageItem(); + imageItem.path = uri.toString(); + imageItem.mimeType = PBitmapUtils.getMimeTypeFromUri(activity, uri); + imageItem.setVideo(MimeType.isVideo(imageItem.mimeType)); + imageItem.setUriPath(uri.toString()); + items.add(imageItem); + } else { + throw new RuntimeException("ImageList item must be instanceof String or Uri or ImageItem"); + } + } + return items; + } + + /** + * 提供媒体相册列表 + * + * @param activity 调用activity + * @param mimeTypeSet 指定相册文件类型 + * @param provider 相回调 + */ + public static void provideMediaSets(FragmentActivity activity, + Set mimeTypeSet, + MediaSetsDataSource.MediaSetProvider provider) { + if (PPermissionUtils.hasStoragePermissions(activity)) { + MediaSetsDataSource.create(activity).setMimeTypeSet(mimeTypeSet).loadMediaSets(provider); + } + } + + /** + * 根据相册提供媒体数据 + * + * @param activity 调用activity + * @param set 相册文件 + * @param mimeTypeSet 加载类型 + * @param provider 媒体文件回调 + */ + public static void provideMediaItemsFromSet(FragmentActivity activity, + ImageSet set, + Set mimeTypeSet, + MediaItemsDataSource.MediaItemProvider provider) { + if (PPermissionUtils.hasStoragePermissions(activity)) { + MediaItemsDataSource.create(activity, set).setMimeTypeSet(mimeTypeSet).loadMediaItems(provider); + } + } + + /** + * 根据相册提供媒体数据,预加载指定数目 + * + * @param activity 调用activity + * @param set 相册文件 + * @param mimeTypeSet 加载类型 + * @param preloadSize 预加载个数 + * @param preloadProvider 预加载回调 + * @param provider 所有文件回调 + */ + public static void provideMediaItemsFromSetWithPreload(FragmentActivity activity, + ImageSet set, + Set mimeTypeSet, + int preloadSize, + MediaItemsDataSource.MediaItemPreloadProvider preloadProvider, + MediaItemsDataSource.MediaItemProvider provider) { + if (PPermissionUtils.hasStoragePermissions(activity)) { + MediaItemsDataSource dataSource = MediaItemsDataSource.create(activity, set) + .setMimeTypeSet(mimeTypeSet) + .preloadSize(preloadSize); + dataSource.setPreloadProvider(preloadProvider); + dataSource.loadMediaItems(provider); + } + } + + + /** + * 提供所有媒体数据 + * + * @param activity 调用activity + * @param mimeTypeSet 加载文件类型 + * @param provider 文件列表回调 + */ + public static void provideAllMediaItems(FragmentActivity activity, + Set mimeTypeSet, + MediaItemsDataSource.MediaItemProvider provider) { + ImageSet set = new ImageSet(); + set.id = ImageSet.ID_ALL_MEDIA; + provideMediaItemsFromSet(activity, set, mimeTypeSet, provider); + } + + /** + * 关闭选择器并回调数据 + * + * @param list 回调数组 + */ + public static void closePickerWithCallback(ArrayList list) { + Activity activity = PickerActivityManager.getLastActivity(); + if (activity == null || list == null || list.size() == 0) { + return; + } + Intent intent = new Intent(); + intent.putExtra(ImagePicker.INTENT_KEY_PICKER_RESULT, list); + activity.setResult(ImagePicker.REQ_PICKER_RESULT_CODE, intent); + activity.finish(); + PickerActivityManager.clear(); + } + + /** + * 关闭选择器并回调数据 + * + * @param imageItem 回调数据 + */ + public static void closePickerWithCallback(ImageItem imageItem) { + ArrayList imageItems = new ArrayList<>(); + imageItems.add(imageItem); + closePickerWithCallback(imageItems); + } + + public static int getThemeColor() { + return themeColor; + } + + public static void setThemeColor(int themeColor) { + ImagePicker.themeColor = themeColor; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java new file mode 100644 index 0000000..db96015 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java @@ -0,0 +1,521 @@ +package com.remax.visualnovel.widget.imagepicker.activity; + +import static com.remax.visualnovel.widget.imagepicker.ImagePicker.REQ_CAMERA; +import static com.remax.visualnovel.widget.imagepicker.ImagePicker.REQ_STORAGE; + +import android.Manifest; +import android.app.Activity; +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.ICameraExecutor; +import com.remax.visualnovel.widget.imagepicker.data.MediaItemsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PPermissionUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PStatusBarUtil; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiProvider; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView; +import com.hjq.permissions.permission.PermissionNames; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + + +/** + * Description: 选择器加载基类,主要处理媒体文件的加载和权限管理 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public abstract class PBaseLoaderFragment extends Fragment implements ICameraExecutor { + //选中图片列表 + protected ArrayList selectList = new ArrayList<>(); + + /** + * @return 获取选择器配置项,主要用于加载文件类型的指定 + */ + @NonNull + protected abstract BaseSelectConfig getSelectConfig(); + + /** + * @return 获取presenter + */ + @NonNull + protected abstract IPickerPresenter getPresenter(); + + /** + * @return 获取presenter + */ + @NonNull + protected abstract PickerUiConfig getUiConfig(); + + /** + * 执行回调 + */ + protected abstract void notifyPickerComplete(); + + /** + * 切换文件夹 + */ + protected abstract void toggleFolderList(); + + /** + * 跳转预览页面 + * + * @param isClickItem 是否是item点击 + * @param index 当前图片位于预览列表数据源的索引 + */ + protected abstract void intentPreview(boolean isClickItem, int index); + + /** + * @param imageSetList 媒体文件夹加载完成回调 + */ + protected abstract void loadMediaSetsComplete(@Nullable List imageSetList); + + /** + * @param set 媒体文件夹内文件加载完成回调 + */ + protected abstract void loadMediaItemsComplete(@Nullable ImageSet set); + + /** + * @param allVideoSet 刷新所有视频的文件夹 + */ + protected abstract void refreshAllVideoSet(@Nullable ImageSet allVideoSet); + + + /** + * @return 返回需要判断当前文件夹列表是否打开 + */ + public boolean onBackPressed() { + return false; + } + + + /** + * @param imageItem 回调一张图片 + */ + protected void notifyOnSingleImagePickComplete(ImageItem imageItem) { + selectList.clear(); + selectList.add(imageItem); + notifyPickerComplete(); + } + + + /** + * 是否超过最大限制数 + * + * @return true:超过 + */ + private boolean isOverMaxCount() { + if (selectList.size() >= getSelectConfig().getMaxCount()) { + getPresenter().overMaxCountTip(getContext(), getSelectConfig().getMaxCount()); + return true; + } + return false; + } + + /** + * 检测当前拍照item是拍照还是录像 + */ + protected void checkTakePhotoOrVideo() { + if (getSelectConfig().isShowVideo() && !getSelectConfig().isShowImage()) { + takeVideo(); + } else { + takePhoto(); + } + } + + /** + * 拍照 + */ + @Override + public void takePhoto() { + if (getActivity() == null || isOverMaxCount()) { + return; + } + if (ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, REQ_CAMERA); + } else { + ImagePicker.takePhoto(getActivity(), null, + true, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + if (items != null && items.size() > 0 && items.get(0) != null) { + onTakePhotoResult(items.get(0)); + } + } + }); + } + } + + @Override + public void takeVideo() { + if (getActivity() == null || isOverMaxCount()) { + return; + } + if (ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, REQ_CAMERA); + } else { + ImagePicker.takeVideo(getActivity(), null, getSelectConfig().getMaxVideoDuration(), + true, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + if (items != null && items.size() > 0 && items.get(0) != null) { + onTakePhotoResult(items.get(0)); + } + } + }); + } + } + + /** + * 加载媒体文件夹 + */ + protected void loadMediaSets() { + if (getActivity() == null) { + return; + } + String permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? PermissionNames.READ_MEDIA_IMAGES : PermissionNames.WRITE_EXTERNAL_STORAGE; + if (ContextCompat.checkSelfPermission(getActivity(), permission) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{permission}, REQ_STORAGE); + } else { + //从媒体库拿到文件夹列表 + ImagePicker.provideMediaSets(getActivity(), getSelectConfig().getMimeTypes(), this::loadMediaSetsComplete); + } + } + + /** + * 根据指定的媒体 文件夹加载文件 + * + * @param set 文件夹 + */ + protected void loadMediaItemsFromSet(final @NonNull ImageSet set) { + if (set.imageItems == null || set.imageItems.size() == 0) { + DialogInterface dialogInterface = null; + if (!set.isAllMedia() && set.count > 1000) { + dialogInterface = getPresenter(). + showProgressDialog(getWeakActivity(), ProgressSceneEnum.loadMediaItem); + } + final BaseSelectConfig selectConfig = getSelectConfig(); + final DialogInterface finalDialogInterface = dialogInterface; + ImagePicker.provideMediaItemsFromSetWithPreload(getActivity(), set, selectConfig.getMimeTypes(), + 40, new MediaItemsDataSource.MediaItemPreloadProvider() { + @Override + public void providerMediaItems(ArrayList imageItems) { + if (finalDialogInterface != null) { + finalDialogInterface.dismiss(); + } + set.imageItems = imageItems; + loadMediaItemsComplete(set); + } + }, new MediaItemsDataSource.MediaItemProvider() { + @Override + public void providerMediaItems(ArrayList imageItems, ImageSet allVideoSet) { + if (finalDialogInterface != null) { + finalDialogInterface.dismiss(); + } + set.imageItems = imageItems; + loadMediaItemsComplete(set); + if (selectConfig.isShowImage() && selectConfig.isShowVideo()) { + refreshAllVideoSet(allVideoSet); + } + } + }); + } else { + loadMediaItemsComplete(set); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == REQ_CAMERA) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + //申请成功,可以拍照 + takePhoto(); + } else { + PPermissionUtils.create(getContext()).showSetPermissionDialog( + getString(R.string.picker_str_camera_permission)); + } + } else if (requestCode == REQ_STORAGE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + //申请成功,可以拍照 + loadMediaSets(); + } else { + PPermissionUtils.create(getContext()). + showSetPermissionDialog(getString(R.string.picker_str_storage_permission)); + } + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + + protected PickerControllerView titleBar; + protected PickerControllerView bottomBar; + + /** + * 加载自定义控制器布局 + * + * @param container 布局容器 + * @param isTitle 是否是顶部栏 + * @param uiConfig ui配置 + * @return 当前需要记载的控制器 + */ + protected PickerControllerView inflateControllerView(ViewGroup container, boolean isTitle, PickerUiConfig uiConfig) { + final BaseSelectConfig selectConfig = getSelectConfig(); + PickerUiProvider uiProvider = uiConfig.getPickerUiProvider(); + PickerControllerView view = isTitle ? uiProvider.getTitleBar(getWeakActivity()) : + uiProvider.getBottomBar(getWeakActivity()); + if (view != null && view.isAddInParent()) { + container.addView(view, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + if (selectConfig.isShowVideo() && selectConfig.isShowImage()) { + view.setTitle(getString(R.string.picker_str_title_all)); + } else if (selectConfig.isShowVideo()) { + view.setTitle(getString(R.string.picker_str_title_video)); + } else { + view.setTitle(getString(R.string.picker_str_title_image)); + } + final PickerControllerView finalView = view; + + View.OnClickListener clickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (v == finalView.getCanClickToCompleteView()) { + notifyPickerComplete(); + } else if (v == finalView.getCanClickToToggleFolderListView()) { + toggleFolderList(); + } else { + intentPreview(false, 0); + } + } + }; + + if (view.getCanClickToCompleteView() != null) { + view.getCanClickToCompleteView().setOnClickListener(clickListener); + } + + if (view.getCanClickToToggleFolderListView() != null) { + view.getCanClickToToggleFolderListView().setOnClickListener(clickListener); + } + + if (view.getCanClickToIntentPreviewView() != null) { + view.getCanClickToIntentPreviewView().setOnClickListener(clickListener); + } + } + + return view; + } + + /** + * 控制器view执行切换文件夹操作 + * + * @param isOpen 是否是打开文件夹 + */ + protected void controllerViewOnTransitImageSet(boolean isOpen) { + if (titleBar != null) { + titleBar.onTransitImageSet(isOpen); + } + if (bottomBar != null) { + bottomBar.onTransitImageSet(isOpen); + } + } + + /** + * 控制器view执行文件夹选择完成 + * + * @param set 当前选择文件夹 + */ + protected void controllerViewOnImageSetSelected(ImageSet set) { + if (titleBar != null) { + titleBar.onImageSetSelected(set); + } + if (bottomBar != null) { + bottomBar.onImageSetSelected(set); + } + } + + /** + * 刷新完成按钮 + */ + protected void refreshCompleteState() { + if (titleBar != null) { + titleBar.refreshCompleteViewState(selectList, getSelectConfig()); + } + + if (bottomBar != null) { + bottomBar.refreshCompleteViewState(selectList, getSelectConfig()); + } + } + + /** + * 设置文件夹列表的高度 + * + * @param mFolderListRecyclerView 文件夹列表 + * @param mImageSetMask 文件夹列表的灰色透明蒙层 + * @param isCrop 是否是小红书样式 + */ + protected void setFolderListHeight(RecyclerView mFolderListRecyclerView, View mImageSetMask, boolean isCrop) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) mFolderListRecyclerView.getLayoutParams(); + RelativeLayout.LayoutParams maskParams = (RelativeLayout.LayoutParams) mImageSetMask.getLayoutParams(); + PickerUiConfig uiConfig = getUiConfig(); + int height = uiConfig.getFolderListOpenMaxMargin(); + if (uiConfig.getFolderListOpenDirection() == PickerUiConfig.DIRECTION_BOTTOM) { + params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE); + if (isCrop) { + params.bottomMargin = bottomBar != null ? bottomBar.getViewHeight() : 0; + params.topMargin = (titleBar != null ? titleBar.getViewHeight() : 0) + height; + maskParams.topMargin = (titleBar != null ? titleBar.getViewHeight() : 0); + maskParams.bottomMargin = bottomBar != null ? bottomBar.getViewHeight() : 0; + } else { + params.bottomMargin = 0; + params.topMargin = height; + } + } else { + params.addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE); + if (isCrop) { + params.bottomMargin = height + (bottomBar != null ? bottomBar.getViewHeight() : 0); + params.topMargin = titleBar != null ? titleBar.getViewHeight() : 0; + maskParams.topMargin = (titleBar != null ? titleBar.getViewHeight() : 0); + maskParams.bottomMargin = bottomBar != null ? bottomBar.getViewHeight() : 0; + } else { + params.bottomMargin = height; + params.topMargin = 0; + } + } + mFolderListRecyclerView.setLayoutParams(params); + mImageSetMask.setLayoutParams(maskParams); + } + + /** + * 是否拦截不可点击的item + * + * @param disableItemCode 不可点击的item的code码 + * @param isCheckOverMaxCount 是否校验超过最大数量时候的item + * @return 是否拦截掉 + */ + protected boolean interceptClickDisableItem(int disableItemCode, boolean isCheckOverMaxCount) { + if (disableItemCode != PickerItemDisableCode.NORMAL) { + if (!isCheckOverMaxCount && disableItemCode == PickerItemDisableCode.DISABLE_OVER_MAX_COUNT) { + return false; + } + String message = PickerItemDisableCode.getMessageFormCode(getActivity(), disableItemCode, getPresenter(), getSelectConfig()); + if (message.length() > 0) { + getPresenter().tip(getWeakActivity(), message); + } + return true; + } + return false; + } + + + /** + * 添加一个图片到文件夹列表里。一般在拍照完成的回调里会执行该方法,用于手动添加 + * 一个item到指定的文件夹列表里 + * + * @param imageSets 当前的文件夹列表 + * @param imageItems 当前文件夹列表里面的item数组 + * @param imageItem 当前要插入的文件 + */ + protected void addItemInImageSets(@NonNull List imageSets, + @NonNull List imageItems, + @NonNull ImageItem imageItem) { + imageItems.add(0, imageItem); + if (imageSets.size() == 0) { + String firstImageSetName; + if (imageItem.isVideo()) { + firstImageSetName = getActivity().getString(R.string.picker_str_folder_item_video); + } else { + firstImageSetName = getActivity().getString(R.string.picker_str_folder_item_image); + } + ImageSet imageSet = ImageSet.allImageSet(firstImageSetName); + imageSet.cover = imageItem; + imageSet.coverPath = imageItem.path; + imageSet.imageItems = (ArrayList) imageItems; + imageSet.count = imageSet.imageItems.size(); + imageSets.add(imageSet); + } else { + imageSets.get(0).imageItems = (ArrayList) imageItems; + imageSets.get(0).cover = imageItem; + imageSets.get(0).coverPath = imageItem.path; + imageSets.get(0).count = imageItems.size(); + } + } + + private WeakReference weakReference; + + /** + * @return 获取弱引用的activity对象 + */ + protected Activity getWeakActivity() { + if (getActivity() != null) { + if (weakReference == null) { + weakReference = new WeakReference(getActivity()); + } + return weakReference.get(); + } + return null; + } + + protected void tip(String msg) { + getPresenter().tip(getWeakActivity(), msg); + } + + final public int dp(float dp) { + if (getActivity() == null || getContext() == null) { + return 0; + } + float density = getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + private long lastTime = 0L; + + protected boolean onDoubleClick() { + boolean flag = false; + long time = System.currentTimeMillis() - lastTime; + + if (time > 300) { + flag = true; + } + lastTime = System.currentTimeMillis(); + return !flag; + } + + /** + * 设置是否显示状态栏 + */ + protected void setStatusBar() { + if (getActivity() != null) { + //刘海屏幕需要适配状态栏颜色 + if (getUiConfig().isShowStatusBar() || PStatusBarUtil.hasNotchInScreen(getActivity())) { + PStatusBarUtil.setStatusBar(getActivity(), getUiConfig().getStatusBarColor(), + false, PStatusBarUtil.isDarkColor(getUiConfig().getStatusBarColor())); + } else { + PStatusBarUtil.fullScreen(getActivity()); + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java new file mode 100644 index 0000000..14dafa2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java @@ -0,0 +1,66 @@ +package com.remax.visualnovel.widget.imagepicker.activity; + +import android.app.Activity; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + +/** + * Time: 2019/11/6 17:09 + * Author:ypx + * Description: 自定义activity栈 + */ +public class PickerActivityManager { + + private static List> activities = new ArrayList<>(); + + public static void addActivity(Activity activity) { + WeakReference activityWeakReference = new WeakReference<>(activity); + if (activities == null) { + activities = new ArrayList<>(); + } + activities.add(activityWeakReference); + } + + public static void removeActivity(Activity activity) { + if (activities == null || activities.size() == 0) { + return; + } + WeakReference activityWeakReference = null; + for (WeakReference activityWeakReference1 : activities) { + if (activityWeakReference1 != null + && activityWeakReference1.get() != null + && activityWeakReference1.get() == activity) { + activityWeakReference = activityWeakReference1; + break; + } + } + if (activityWeakReference != null) { + activities.remove(activityWeakReference); + } + } + + public static Activity getLastActivity() { + if (activities != null && activities.size() > 0) { + WeakReference activityWeakReference = activities.get(activities.size() - 1); + if (activityWeakReference != null) { + return activityWeakReference.get(); + } + } + return null; + } + + public static void clear() { + if (activities != null && activities.size() > 0) { + for (int i = 0; i < activities.size(); i++) { + WeakReference activityWeakReference = activities.get(i); + if (activityWeakReference.get() != null && !activityWeakReference.get().isDestroyed()) { + activityWeakReference.get().finish(); + } + } + activities.clear(); + activities = null; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java new file mode 100644 index 0000000..6dbfa4a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java @@ -0,0 +1,122 @@ +package com.remax.visualnovel.widget.imagepicker.activity.crop; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; +import com.remax.visualnovel.widget.imagepicker.data.PickerActivityCallBack; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; + + +import java.util.ArrayList; + +/** + * Description: 图片选择和剪裁页面 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImageCropActivity extends AppCompatActivity { + public static final String INTENT_KEY_DATA_PRESENTER = "ICropPickerBindPresenter"; + public static final String INTENT_KEY_SELECT_CONFIG = "selectConfig"; + private MultiImageCropFragment mFragment; + private IPickerPresenter presenter; + private CropSelectConfig selectConfig; + + /** + * 跳转小红书剪裁页面 + * + * @param activity 跳转activity + * @param presenter ICropPickerBindPresenter + * @param selectConfig 选择器配置 + * @param listener 选择回调 + */ + public static void intent(@NonNull Activity activity, @NonNull IPickerPresenter presenter, + @NonNull CropSelectConfig selectConfig, final @NonNull OnImagePickCompleteListener listener) { + if (!PViewSizeUtils.onDoubleClick()) { + Intent intent = new Intent(activity, MultiImageCropActivity.class); + intent.putExtra(MultiImageCropActivity.INTENT_KEY_DATA_PRESENTER, presenter); + intent.putExtra(MultiImageCropActivity.INTENT_KEY_SELECT_CONFIG, selectConfig); + PLauncher.init(activity).startActivityForResult(intent, PickerActivityCallBack.create(listener)); + } + } + + /** + * 校验传递数据是否合法 + */ + private boolean isIntentDataFailed() { + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_DATA_PRESENTER); + selectConfig = (CropSelectConfig) getIntent().getSerializableExtra(INTENT_KEY_SELECT_CONFIG); + if (presenter == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return true; + } + if (selectConfig == null) { + PickerErrorExecutor.executeError(this, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return true; + } + return false; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (isIntentDataFailed()) { + return; + } + PickerActivityManager.addActivity(this); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.picker_activity_fragment_wrapper); + setFragment(); + } + + /** + * 填充fragment + */ + private void setFragment() { + mFragment = ImagePicker.withCrop(presenter) + .withSelectConfig(selectConfig) + .pickWithFragment(new OnImagePickCompleteListener2() { + @Override + public void onPickFailed(PickerError error) { + PickerErrorExecutor.executeError(MultiImageCropActivity.this, error.getCode()); + PickerActivityManager.clear(); + } + + @Override + public void onImagePickComplete(ArrayList items) { + ImagePicker.closePickerWithCallback(items); + } + }); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, mFragment) + .commit(); + } + + @Override + public void onBackPressed() { + if (null != mFragment && mFragment.onBackPressed()) { + return; + } + super.onBackPressed(); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java new file mode 100644 index 0000000..44588bb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java @@ -0,0 +1,827 @@ +package com.remax.visualnovel.widget.imagepicker.activity.crop; + +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + + + + +import java.util.ArrayList; +import java.util.List; + +import static com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropActivity.INTENT_KEY_DATA_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropActivity.INTENT_KEY_SELECT_CONFIG; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PBaseLoaderFragment; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerFolderAdapter; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerItemAdapter; +import com.remax.visualnovel.widget.imagepicker.bean.ImageCropMode; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.CropViewContainerHelper; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.RecyclerViewTouchHelper; +import com.remax.visualnovel.widget.imagepicker.helper.VideoViewContainerHelper; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PCornerUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.widget.TouchRecyclerView; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + + +/** + * Description: 图片选择和剪裁fragment + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImageCropFragment extends PBaseLoaderFragment implements View.OnClickListener, + PickerFolderAdapter.FolderSelectResult, + PickerItemAdapter.OnActionResult { + private TouchRecyclerView mGridImageRecyclerView; + private RecyclerView mFolderListRecyclerView; + private TextView mTvFullOrGap; + private CropImageView mCropView; + private ImageButton stateBtn; + private FrameLayout mCropContainer; + private RelativeLayout mCropLayout; + private LinearLayout mInvisibleContainer; + private View maskView, mImageSetMasker; + private PickerItemAdapter imageGridAdapter; + private PickerFolderAdapter folderAdapter; + private List imageSets = new ArrayList<>(); + private List imageItems = new ArrayList<>(); + private int mCropSize; + private int pressImageIndex = 0; + //滑动辅助类 + private RecyclerViewTouchHelper touchHelper; + //图片加载提供者 + private IPickerPresenter presenter; + //选择配置项 + private CropSelectConfig selectConfig; + // 默认剪裁模式:充满 + private int cropMode = ImageCropMode.CropViewScale_FULL; + private ImageItem currentImageItem; + private View mContentView; + // fragment 形式调用的图片选中回调 + private OnImagePickCompleteListener imageListener; + //剪裁view或videoView填充辅助类 + private CropViewContainerHelper cropViewContainerHelper; + private VideoViewContainerHelper videoViewContainerHelper; + //UI配置类 + private PickerUiConfig uiConfig; + + private FrameLayout titleBarContainer; + private FrameLayout bottomBarContainer; + private FrameLayout titleBarContainer2; + + private ImageItem lastPressItem; + + /** + * @param imageListener 选择回调监听 + */ + public void setOnImagePickCompleteListener(@NonNull OnImagePickCompleteListener imageListener) { + this.imageListener = imageListener; + } + + @NonNull + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container, @NonNull Bundle savedInstanceState) { + mContentView = inflater.inflate(R.layout.picker_activity_multi_crop, container, false); + return mContentView; + } + + @Override + public void onViewCreated(@NonNull View view, @NonNull Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (isIntentDataValid()) { + ImagePicker.isOriginalImage = false; + uiConfig = presenter.getUiConfig(getWeakActivity()); + setStatusBar(); + initView(); + initUI(); + initGridImagesAndImageSets(); + loadMediaSets(); + } + } + + /** + * 校验传递数据 + */ + private boolean isIntentDataValid() { + Bundle arguments = getArguments(); + if (null != arguments) { + presenter = (IPickerPresenter) arguments.getSerializable(INTENT_KEY_DATA_PRESENTER); + selectConfig = (CropSelectConfig) arguments.getSerializable(INTENT_KEY_SELECT_CONFIG); + } + + if (presenter == null) { + PickerErrorExecutor.executeError(imageListener, PickerError.PRESENTER_NOT_FOUND.getCode()); + return false; + } + + if (selectConfig == null) { + PickerErrorExecutor.executeError(imageListener, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return false; + } + return true; + } + + /** + * 初始化界面 + */ + private void initView() { + titleBarContainer = mContentView.findViewById(R.id.titleBarContainer); + titleBarContainer2 = mContentView.findViewById(R.id.titleBarContainer2); + bottomBarContainer = mContentView.findViewById(R.id.bottomBarContainer); + mTvFullOrGap = mContentView.findViewById(R.id.mTvFullOrGap); + mImageSetMasker = mContentView.findViewById(R.id.mImageSetMasker); + maskView = mContentView.findViewById(R.id.v_mask); + mCropContainer = mContentView.findViewById(R.id.mCroupContainer); + mInvisibleContainer = mContentView.findViewById(R.id.mInvisibleContainer); + RelativeLayout topView = mContentView.findViewById(R.id.topView); + mCropLayout = mContentView.findViewById(R.id.mCropLayout); + stateBtn = mContentView.findViewById(R.id.stateBtn); + mGridImageRecyclerView = mContentView.findViewById(R.id.mRecyclerView); + mFolderListRecyclerView = mContentView.findViewById(R.id.mImageSetRecyclerView); + mTvFullOrGap.setBackground(PCornerUtils.cornerDrawable(Color.parseColor("#80000000"), dp(15))); + //初始化监听 + stateBtn.setOnClickListener(this); + maskView.setOnClickListener(this); + mImageSetMasker.setOnClickListener(this); + mTvFullOrGap.setOnClickListener(this); + //防止点击穿透 + mCropLayout.setClickable(true); + //蒙层隐藏 + maskView.setAlpha(0f); + maskView.setVisibility(View.GONE); + //初始化相关尺寸信息 + mCropSize = PViewSizeUtils.getScreenWidth(getActivity()); + PViewSizeUtils.setViewSize(mCropLayout, mCropSize, 1.0f); + //recyclerView和topView的联动效果辅助类 + touchHelper = RecyclerViewTouchHelper.create(mGridImageRecyclerView) + .setTopView(topView) + .setMaskView(maskView) + .setCanScrollHeight(mCropSize) + .build(); + //剪裁控件辅助类 + cropViewContainerHelper = new CropViewContainerHelper(mCropContainer); + //视频控件辅助类 + videoViewContainerHelper = new VideoViewContainerHelper(); + //指定默认剪裁模式 + if (selectConfig.hasFirstImageItem()) { + cropMode = selectConfig.getFirstImageItem().getCropMode(); + } + } + + /** + * 初始化自定义样式 + */ + private void initUI() { + //拿到自定义标题栏和底部栏 + titleBar = inflateControllerView(titleBarContainer, true, uiConfig); + bottomBar = inflateControllerView(bottomBarContainer, false, uiConfig); + //如果包含标题栏 + if (titleBar != null) { + PViewSizeUtils.setMarginTop(mCropLayout, titleBar.getViewHeight()); + touchHelper.setStickHeight(titleBar.getViewHeight()); + } + //如果包含底部栏 + if (bottomBar != null) { + PViewSizeUtils.setMarginTopAndBottom(mGridImageRecyclerView, 0, bottomBar.getViewHeight()); + } + //设置基础样式 + mCropContainer.setBackgroundColor(uiConfig.getCropViewBackgroundColor()); + mGridImageRecyclerView.setBackgroundColor(uiConfig.getPickerBackgroundColor()); + stateBtn.setImageDrawable(getResources().getDrawable(uiConfig.getFullIconID())); + mTvFullOrGap.setCompoundDrawablesWithIntrinsicBounds(getResources(). + getDrawable(uiConfig.getFillIconID()), null, null, null); + //设置相册列表高度 + setFolderListHeight(mFolderListRecyclerView, mImageSetMasker, true); + } + + /** + * 初始化图片列表 + */ + private void initGridImagesAndImageSets() { + GridLayoutManager gridLayoutManager = new GridLayoutManager(getContext(), selectConfig.getColumnCount()); + mGridImageRecyclerView.setLayoutManager(gridLayoutManager); + imageGridAdapter = new PickerItemAdapter(selectList, imageItems, selectConfig, presenter, uiConfig); + imageGridAdapter.setHasStableIds(true); + mGridImageRecyclerView.setAdapter(imageGridAdapter); + //初始化文件夹列表 + mFolderListRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + folderAdapter = new PickerFolderAdapter(presenter, uiConfig); + mFolderListRecyclerView.setAdapter(folderAdapter); + folderAdapter.refreshData(imageSets); + mFolderListRecyclerView.setVisibility(View.GONE); + folderAdapter.setFolderSelectResult(this); + imageGridAdapter.setOnActionResult(this); + } + + @Override + public void onClick(@NonNull View view) { + if (imageItems == null || imageItems.size() == 0) { + return; + } + if (onDoubleClick()) { + tip(getActivity().getString(R.string.picker_str_tip_action_frequently)); + return; + } + if (view == stateBtn) { + fullOrFit(); + } else if (view == maskView) { + touchHelper.transitTopWithAnim(true, pressImageIndex, true); + } else if (view == mTvFullOrGap) { + fullOrGap(); + } else if (mImageSetMasker == view) { + toggleFolderList(); + } + } + + + /** + * 点击操作 + * + * @param imageItem 当前item + * @param position 当前item的position + */ + @Override + public void onClickItem(@NonNull ImageItem imageItem, int position, int disableItemCode) { + //拍照 + if (position <= 0 && selectConfig.isShowCamera()) { + //拦截拍照点击 + if (presenter.interceptCameraClick(getWeakActivity(), this)) { + return; + } + checkTakePhotoOrVideo(); + return; + } + + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, false)) { + return; + } + + //得到当前选中的item索引 + pressImageIndex = position; + //防止数组越界 + if (imageItems == null || imageItems.size() == 0 || + imageItems.size() <= pressImageIndex) { + return; + } + + //是否拦截当前item的点击事件 + if (isInterceptItemClick(imageItem, false)) { + return; + } + + //选中当前item + onPressImage(imageItem, true); + } + + + private boolean isInterceptItemClick(ImageItem imageItem, boolean isClickCheckbox) { + return !imageGridAdapter.isPreformClick() && presenter.interceptItemClick(getWeakActivity(), imageItem, selectList, + (ArrayList) imageItems, selectConfig, imageGridAdapter, isClickCheckbox, + null); + } + + /** + * 点击图片 + * + * @param imageItem 图片 + */ + private void onPressImage(ImageItem imageItem, boolean isShowTransit) { + currentImageItem = imageItem; + if (lastPressItem != null) { + //如果当前选中的item和上一次选中的一致,则不处理 + if (lastPressItem.equals(currentImageItem)) { + return; + } + //取消上次选中 + lastPressItem.setPress(false); + } + currentImageItem.setPress(true); + //当前选中视频 + if (currentImageItem.isVideo()) { + if (selectConfig.isVideoSinglePickAndAutoComplete()) { + notifyOnSingleImagePickComplete(imageItem); + return; + } + //执行预览视频操作 + videoViewContainerHelper.loadVideoView(mCropContainer, currentImageItem, presenter, uiConfig); + } else { + //加载图片 + loadCropView(); + } + checkStateBtn(); + imageGridAdapter.notifyDataSetChanged(); + touchHelper.transitTopWithAnim(true, pressImageIndex, isShowTransit); + lastPressItem = currentImageItem; + } + + + /** + * 执行选中(取消选中)操作 + * + * @param imageItem 当前item + */ + @Override + public void onCheckItem(ImageItem imageItem, int disableItemCode) { + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, true)) { + return; + } + + //是否拦截当前item的点击事件 + if (isInterceptItemClick(imageItem, true)) { + return; + } + + //如果当前选中列表已经包含了此item,则移除并刷新 + if (selectList.contains(imageItem)) { + removeImageItemFromCropViewList(imageItem); + checkStateBtn(); + } else { + onPressImage(imageItem, false); + addImageItemToCropViewList(imageItem); + } + imageGridAdapter.notifyDataSetChanged(); + } + + @Override + public void folderSelected(ImageSet set, int pos) { + selectImageSet(pos, true); + } + + /** + * 点击选中相册 + * + * @param position 相册position + */ + private void selectImageSet(int position, boolean isTransit) { + ImageSet imageSet = imageSets.get(position); + if (imageSet == null) { + return; + } + for (ImageSet set : imageSets) { + set.isSelected = false; + } + imageSet.isSelected = true; + folderAdapter.notifyDataSetChanged(); + if (titleBar != null) { + titleBar.onImageSetSelected(imageSet); + } + if (bottomBar != null) { + bottomBar.onImageSetSelected(imageSet); + } + if (isTransit) { + toggleFolderList(); + } + loadMediaItemsFromSet(imageSet); + } + + /** + * 加载剪裁view + */ + private void loadCropView() { + mCropView = cropViewContainerHelper.loadCropView(getContext(), currentImageItem, mCropSize, + presenter, new CropViewContainerHelper.onLoadComplete() { + @Override + public void loadComplete() { + checkStateBtn(); + } + }); + resetCropViewSize(mCropView, false); + } + + /** + * 添加当前图片信息到选中列表 + */ + private void addImageItemToCropViewList(ImageItem imageItem) { + if (!selectList.contains(imageItem)) { + selectList.add(imageItem); + } + cropViewContainerHelper.addCropView(mCropView, imageItem); + refreshCompleteState(); + } + + /** + * 从选种列表中移除当前图片信息 + */ + private void removeImageItemFromCropViewList(ImageItem imageItem) { + selectList.remove(imageItem); + cropViewContainerHelper.removeCropView(imageItem); + refreshCompleteState(); + } + + /** + * 检测显示填充、留白、充满和自适应图标 + */ + private void checkStateBtn() { + //选中的第一个item是视频,则隐藏所有按钮 + if (currentImageItem.isVideo()) { + stateBtn.setVisibility(View.GONE); + mTvFullOrGap.setVisibility(View.GONE); + return; + } + //方形图,什么都不显示 + if (currentImageItem.getWidthHeightType() == 0) { + stateBtn.setVisibility(View.GONE); + mTvFullOrGap.setVisibility(View.GONE); + return; + } + //如果已经存在了第一张选中图 + if (selectConfig.hasFirstImageItem()) { + stateBtn.setVisibility(View.GONE); + if (selectConfig.isAssignGapState()) { + if (selectList.size() == 0 || (selectList.get(0) != null + && selectList.get(0).equals(currentImageItem))) { + setImageScaleState(); + } else { + mTvFullOrGap.setVisibility(View.GONE); + if (selectList.get(0).getCropMode() == ImageCropMode.ImageScale_GAP) { + mCropView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + mCropView.setBackgroundColor(Color.WHITE); + } else { + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + mCropView.setBackgroundColor(Color.TRANSPARENT); + } + } + } else { + setImageScaleState(); + } + return; + } + + //当选中图片数量大于0 时 + if (selectList.size() > 0) { + //如果当前选中item就是第一个图片,显示stateBtn + if (currentImageItem == selectList.get(0)) { + stateBtn.setVisibility(View.VISIBLE); + mTvFullOrGap.setVisibility(View.GONE); + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + currentImageItem.setCropMode(cropMode); + } else { + //如果当前选中item不是第一张图片,显示mTvFullOrGap + stateBtn.setVisibility(View.GONE); + setImageScaleState(); + } + } else {//没有选中图片 + stateBtn.setVisibility(View.VISIBLE); + mTvFullOrGap.setVisibility(View.GONE); + } + } + + /** + * 重置剪裁宽高大小 + */ + private void resetCropViewSize(CropImageView view, boolean isShowAnim) { + int height = mCropSize; + int width = mCropSize; + if (cropMode == ImageCropMode.CropViewScale_FIT) { + ImageItem firstImageItem; + //如果已经存在第一张图,则按照第一张图的剪裁模式改变大小 + if (selectConfig.hasFirstImageItem()) { + firstImageItem = selectConfig.getFirstImageItem(); + } else { + //没有已经存在的第一张图信息,则获取选中的第一张图的剪裁模式作为全局的剪裁模式 + if (selectList.size() > 0) { + firstImageItem = selectList.get(0); + } else { + firstImageItem = currentImageItem; + } + } + //如果是宽图,高*3/4 + height = firstImageItem.getWidthHeightType() > 0 ? ((mCropSize * 3) / 4) : mCropSize; + //如果是高图,宽*3/4 + width = firstImageItem.getWidthHeightType() < 0 ? ((mCropSize * 3) / 4) : mCropSize; + } + view.changeSize(isShowAnim, width, height); + } + + + /** + * 第一张图片剪裁区域充满或者自适应(是剪裁区域,不是图片填充和留白) + */ + private void fullOrFit() { + if (cropMode == ImageCropMode.CropViewScale_FIT) { + cropMode = ImageCropMode.CropViewScale_FULL; + stateBtn.setImageDrawable(getResources().getDrawable(uiConfig.getFitIconID())); + } else { + cropMode = ImageCropMode.CropViewScale_FIT; + stateBtn.setImageDrawable(getResources().getDrawable(uiConfig.getFullIconID())); + } + if (currentImageItem != null) { + currentImageItem.setCropMode(cropMode); + } + + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + resetCropViewSize(mCropView, true); + //以下是重置所有选中图片剪裁模式 + cropViewContainerHelper.refreshAllState(currentImageItem, selectList, mInvisibleContainer, + cropMode == ImageCropMode.CropViewScale_FIT, + new CropViewContainerHelper.ResetSizeExecutor() { + @Override + public void resetAllCropViewSize(CropImageView view) { + resetCropViewSize(view, false); + } + }); + } + + + /** + * 设置留白还是填充 + */ + private void setImageScaleState() { + //如果当前模式为自适应模式 + if (cropMode == ImageCropMode.CropViewScale_FIT) { + //如果当前图片和第一张选中图片的宽高类型一样,则不显示留白和充满 + mTvFullOrGap.setVisibility(View.GONE); + } else { + //如果第一张图为充满模式,则不论宽高比(除正方形外),都显示留白和充满 + mTvFullOrGap.setVisibility(View.VISIBLE); + //如果当前已选中该图片,则恢复选择时的填充和留白状态 + if (selectList.contains(currentImageItem)) { + if (currentImageItem.getCropMode() == ImageCropMode.ImageScale_FILL) { + fullState(); + } else if (currentImageItem.getCropMode() == ImageCropMode.ImageScale_GAP) { + gapState(); + } + } else { + //否则都按照默认填充的模式,显示留白提示 + fullState(); + currentImageItem.setCropMode(ImageCropMode.ImageScale_FILL); + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + } + } + } + + /** + * 充满或者留白 + */ + private void fullOrGap() { + //留白 + if (currentImageItem.getCropMode() == ImageCropMode.ImageScale_FILL) { + currentImageItem.setCropMode(ImageCropMode.ImageScale_GAP); + mCropView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + gapState(); + } else { + //充满 + currentImageItem.setCropMode(ImageCropMode.ImageScale_FILL); + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + fullState(); + } + resetCropViewSize(mCropView, false); + } + + /** + * 留白情况下,显示充满状态 + */ + private void gapState() { + mTvFullOrGap.setText(getString(R.string.picker_str_redBook_full)); + mCropView.setBackgroundColor(Color.WHITE); + mTvFullOrGap.setCompoundDrawablesWithIntrinsicBounds(getResources(). + getDrawable(uiConfig.getFillIconID()), null, null, null); + } + + /** + * 充满情况下,显示留白状态 + */ + private void fullState() { + mTvFullOrGap.setText(getString(R.string.picker_str_redBook_gap)); + mCropView.setBackgroundColor(Color.TRANSPARENT); + mTvFullOrGap.setCompoundDrawablesWithIntrinsicBounds(getResources(). + getDrawable(uiConfig.getGapIconID()), null, null, null); + } + + + /** + * 刷新选中图片列表,执行回调,退出页面 + */ + @Override + protected void notifyPickerComplete() { + //如果当前选择的都是视频 + if (selectList.size() > 0 && selectList.get(0).isVideo()) { + } else { + //正在编辑 + if (mCropView.isEditing()) { + return; + } + //未加载出图片 + if (selectList.contains(currentImageItem) + && (mCropView.getDrawable() == null || + mCropView.getDrawable().getIntrinsicHeight() == 0 || + mCropView.getDrawable().getIntrinsicWidth() == 0)) { + tip(getString(R.string.picker_str_tip_shield)); + return; + } + selectList = cropViewContainerHelper.generateCropUrls(selectList, cropMode); + } + + //如果拦截了完成操作,则执行自定义的拦截操作 + if (!presenter.interceptPickerCompleteClick(getWeakActivity(), selectList, selectConfig)) { + if (null != imageListener) { + imageListener.onImagePickComplete(selectList); + } + } + } + + + @Override + protected void toggleFolderList() { + if (mFolderListRecyclerView.getVisibility() == View.GONE) { + View view = titleBarContainer.getChildAt(0); + if (view == null) { + return; + } + titleBarContainer.removeAllViews(); + titleBarContainer2.removeAllViews(); + titleBarContainer2.addView(view); + + mImageSetMasker.setVisibility(View.VISIBLE); + controllerViewOnTransitImageSet(true); + mFolderListRecyclerView.setVisibility(View.VISIBLE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(getActivity(), + uiConfig.isShowFromBottom() ? R.anim.picker_show2bottom : R.anim.picker_anim_in)); + + } else { + final View view = titleBarContainer2.getChildAt(0); + if (view == null) { + return; + } + mImageSetMasker.setVisibility(View.GONE); + controllerViewOnTransitImageSet(false); + mFolderListRecyclerView.setVisibility(View.GONE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(getActivity(), + uiConfig.isShowFromBottom() ? R.anim.picker_hide2bottom : R.anim.picker_anim_up)); + + titleBarContainer2.postDelayed(new Runnable() { + @Override + public void run() { + titleBarContainer2.removeAllViews(); + titleBarContainer.removeAllViews(); + titleBarContainer.addView(view); + } + }, 300); + } + + } + + @Override + protected void intentPreview(boolean isFolderListPreview, int index) { + + } + + @Override + protected void loadMediaSetsComplete(@Nullable List imageSetList) { + if (imageSetList == null || imageSetList.size() == 0 || + (imageSetList.size() == 1 && imageSetList.get(0).count == 0)) { + tip(getString(R.string.picker_str_tip_media_empty)); + return; + } + this.imageSets = imageSetList; + folderAdapter.refreshData(imageSets); + selectImageSet(0, false); + } + + @Override + protected void loadMediaItemsComplete(@NonNull ImageSet set) { + if (set.imageItems != null && set.imageItems.size() > 0) { + imageItems.clear(); + imageItems.addAll(set.imageItems); + imageGridAdapter.notifyDataSetChanged(); + int firstImageIndex = getCanPressItemPosition(); + if (firstImageIndex < 0) { + return; + } + int index = selectConfig.isShowCamera() ? firstImageIndex + 1 : firstImageIndex; + onClickItem(imageItems.get(firstImageIndex), index, PickerItemDisableCode.NORMAL); + } + } + + /** + * @return 获取第一个有效的item(可以选择的) + */ + private int getCanPressItemPosition() { + for (int i = 0; i < imageItems.size(); i++) { + ImageItem imageItem = imageItems.get(i); + if (imageItem.isVideo() && selectConfig.isVideoSinglePickAndAutoComplete()) { + continue; + } + int code = PickerItemDisableCode.getItemDisableCode(imageItem, selectConfig, + selectList, false); + if (code == PickerItemDisableCode.NORMAL) { + return i; + } + } + return -1; + } + + @Override + protected void refreshAllVideoSet(@Nullable ImageSet allVideoSet) { + if (allVideoSet != null && + allVideoSet.imageItems != null + && allVideoSet.imageItems.size() > 0 + && !imageSets.contains(allVideoSet)) { + imageSets.add(1, allVideoSet); + folderAdapter.refreshData(imageSets); + } + } + + /** + * 相册选择是否打开 + */ + @Override + public boolean onBackPressed() { + if (mFolderListRecyclerView != null && mFolderListRecyclerView.getVisibility() == View.VISIBLE) { + toggleFolderList(); + return true; + } + if (presenter != null && presenter.interceptPickerCancel(getWeakActivity(), selectList)) { + return true; + } + PickerErrorExecutor.executeError(imageListener, PickerError.CANCEL.getCode()); + return false; + } + + + @Override + public void onTakePhotoResult(@Nullable ImageItem imageItem) { + if (imageItem != null) { + addItemInImageSets(imageSets, imageItems, imageItem); + onCheckItem(imageItem, PickerItemDisableCode.NORMAL); + imageGridAdapter.notifyDataSetChanged(); + } + } + + + @Override + protected BaseSelectConfig getSelectConfig() { + return selectConfig; + } + + @Override + protected IPickerPresenter getPresenter() { + return presenter; + } + + @Override + protected PickerUiConfig getUiConfig() { + return uiConfig; + } + + @Override + public void onDestroy() { + //将VideoView所占用的资源释放掉 + if (videoViewContainerHelper != null) { + videoViewContainerHelper.onDestroy(); + } + uiConfig.setPickerUiProvider(null); + uiConfig = null; + presenter = null; + super.onDestroy(); + } + + @Override + public void onResume() { + super.onResume(); + if (videoViewContainerHelper != null) { + videoViewContainerHelper.onResume(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (videoViewContainerHelper != null) { + videoViewContainerHelper.onPause(); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java new file mode 100644 index 0000000..36a8125 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java @@ -0,0 +1,129 @@ +package com.remax.visualnovel.widget.imagepicker.activity.multi; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; +import com.remax.visualnovel.widget.imagepicker.data.PickerActivityCallBack; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; + + +import java.util.ArrayList; + +/** + * Description: 多选页 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImagePickerActivity extends AppCompatActivity { + public static final String INTENT_KEY_SELECT_CONFIG = "MultiSelectConfig"; + public static final String INTENT_KEY_PRESENTER = "IPickerPresenter"; + public static final String INTENT_KEY_CURRENT_INDEX = "currentIndex"; + public static final String INTENT_KEY_CURRENT_IMAGE = "currentImage"; + + private MultiImagePickerFragment fragment; + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + + /** + * 跳转微信选择器页面 + * + * @param activity 跳转的activity + * @param selectConfig 配置项 + * @param presenter IMultiPickerBindPresenter + * @param listener 选择回调 + */ + public static void intent(@NonNull Activity activity, @NonNull MultiSelectConfig selectConfig, + @NonNull IPickerPresenter presenter, @NonNull OnImagePickCompleteListener listener) { + if (PViewSizeUtils.onDoubleClick()) { + return; + } + Intent intent = new Intent(activity, MultiImagePickerActivity.class); + intent.putExtra(MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG, selectConfig); + intent.putExtra(MultiImagePickerActivity.INTENT_KEY_PRESENTER, presenter); + PLauncher.init(activity).startActivityForResult(intent, PickerActivityCallBack.create(listener)); + activity.overridePendingTransition(R.anim.act_slide_in_from_bottom, R.anim.no_anim); + } + + /** + * 校验传递数据是否合法 + */ + private boolean isIntentDataFailed() { + selectConfig = (MultiSelectConfig) getIntent().getSerializableExtra(INTENT_KEY_SELECT_CONFIG); + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_PRESENTER); + if (presenter == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return true; + } + if (selectConfig == null) { + PickerErrorExecutor.executeError(this, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return true; + } + return false; + } + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (isIntentDataFailed()) { + return; + } + PickerActivityManager.addActivity(this); + setContentView(R.layout.picker_activity_fragment_wrapper); + setFragment(); + } + + /** + * 填充选择器fragment + */ + private void setFragment() { + fragment = ImagePicker.withMulti(presenter) + .withMultiSelectConfig(selectConfig) + .pickWithFragment(new OnImagePickCompleteListener2() { + @Override + public void onPickFailed(PickerError error) { + PickerErrorExecutor.executeError(MultiImagePickerActivity.this, error.getCode()); + PickerActivityManager.clear(); + } + + @Override + public void onImagePickComplete(ArrayList items) { + ImagePicker.closePickerWithCallback(items); + } + }); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, fragment) + .commit(); + } + + @Override + public void onBackPressed() { + if (fragment != null && fragment.onBackPressed()) { + return; + } + super.onBackPressed(); + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(R.anim.no_anim,R.anim.act_slide_out_from_bottom); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java new file mode 100644 index 0000000..f7e8f0a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java @@ -0,0 +1,552 @@ +package com.remax.visualnovel.widget.imagepicker.activity.multi; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.app.widget.LoadingDialog; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PBaseLoaderFragment; +import com.remax.visualnovel.widget.imagepicker.activity.preview.MultiImagePreviewActivity; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerFolderAdapter; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerItemAdapter; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.SelectMode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.IReloadExecutor; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.MediaUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.itemdecoration.GridSpaceItemDecoration; + +import java.util.ArrayList; +import java.util.List; + +import timber.log.Timber; + + +/** + * Description: 多选页 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImagePickerFragment extends PBaseLoaderFragment implements View.OnClickListener, + PickerItemAdapter.OnActionResult, IReloadExecutor { + private List imageSets = new ArrayList<>(); + private ArrayList imageItems = new ArrayList<>(); + private RecyclerView mRecyclerView; + private View v_masker; + private TextView mTvTime; + private PickerFolderAdapter mImageSetAdapter; + private RecyclerView mFolderListRecyclerView; + private PickerItemAdapter mAdapter; + private ImageSet currentImageSet; + private FrameLayout titleBarContainer; + private FrameLayout bottomBarContainer; + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + private PickerUiConfig uiConfig; + private FragmentActivity mContext; + private GridLayoutManager layoutManager; + private View view; + + private LoadingDialog loadingDialog; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + view = inflater.inflate(R.layout.picker_activity_multipick, container, false); + return view; + } + + /** + * 校验传递数据是否合法 + */ + private boolean isIntentDataValid() { + Bundle bundle = getArguments(); + if (bundle != null) { + selectConfig = (MultiSelectConfig) bundle.getSerializable(INTENT_KEY_SELECT_CONFIG); + presenter = (IPickerPresenter) bundle.getSerializable(INTENT_KEY_PRESENTER); + if (presenter == null) { + PickerErrorExecutor.executeError(onImagePickCompleteListener, + PickerError.PRESENTER_NOT_FOUND.getCode()); + return false; + } + if (selectConfig == null) { + PickerErrorExecutor.executeError(onImagePickCompleteListener, + PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return false; + } + return true; + } else { + return false; + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mContext = getActivity(); + if (isIntentDataValid()) { + ImagePicker.isOriginalImage = selectConfig.isDefaultOriginal(); + uiConfig = presenter.getUiConfig(getWeakActivity()); + setStatusBar(); + findView(); + if (selectConfig.getLastImageList() != null) { + selectList.addAll(selectConfig.getLastImageList()); + } + loadMediaSets(); + refreshCompleteState(); + } + } + + private OnImagePickCompleteListener onImagePickCompleteListener; + + /** + * 设置图片选择器完成回调 + * + * @param onImagePickCompleteListener 完成回调 + */ + public void setOnImagePickCompleteListener(@NonNull OnImagePickCompleteListener onImagePickCompleteListener) { + this.onImagePickCompleteListener = onImagePickCompleteListener; + } + + /** + * 初始化控件 + */ + private void findView() { + v_masker = view.findViewById(R.id.v_masker); + mRecyclerView = view.findViewById(R.id.mRecyclerView); + mFolderListRecyclerView = view.findViewById(R.id.mSetRecyclerView); + mTvTime = view.findViewById(R.id.tv_time); + mTvTime.setVisibility(View.GONE); + titleBarContainer = view.findViewById(R.id.titleBarContainer); + bottomBarContainer = view.findViewById(R.id.bottomBarContainer); + initAdapters(); + initUI(); + setListener(); + refreshCompleteState(); + } + + private RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + if (mTvTime.getVisibility() == View.VISIBLE) { + mTvTime.setVisibility(View.GONE); + mTvTime.startAnimation(AnimationUtils.loadAnimation(mContext, R.anim.picker_fade_out)); + } + } else { + if (mTvTime.getVisibility() == View.GONE) { + mTvTime.setVisibility(View.VISIBLE); + mTvTime.startAnimation(AnimationUtils.loadAnimation(mContext, R.anim.picker_fade_in)); + } + } + } + + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + if (imageItems != null) + try { + mTvTime.setText(imageItems.get(layoutManager.findFirstVisibleItemPosition()).getTimeFormat()); + } catch (Exception ignored) { + + } + } + }; + + /** + * 初始化UI界面 + */ + private void initUI() { + mRecyclerView.setBackgroundColor(uiConfig.getPickerBackgroundColor()); + titleBar = inflateControllerView(titleBarContainer, true, uiConfig); + bottomBar = inflateControllerView(bottomBarContainer, false, uiConfig); + setFolderListHeight(mFolderListRecyclerView, v_masker, false); + } + + /** + * 初始化监听 + */ + private void setListener() { + v_masker.setOnClickListener(this); +// mRecyclerView.addOnScrollListener(onScrollListener); + mImageSetAdapter.setFolderSelectResult((set, pos) -> selectImageFromSet(pos, true)); + } + + /** + * 初始化相关adapter + */ + private void initAdapters() { + mFolderListRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + mImageSetAdapter = new PickerFolderAdapter(presenter, uiConfig); + mFolderListRecyclerView.setAdapter(mImageSetAdapter); + mImageSetAdapter.refreshData(imageSets); + + mAdapter = new PickerItemAdapter(selectList, new ArrayList(), selectConfig, presenter, uiConfig); + mAdapter.setHasStableIds(true); + mAdapter.setOnActionResult(this); + layoutManager = new GridLayoutManager(mContext, selectConfig.getColumnCount()); + mRecyclerView.addItemDecoration(new GridSpaceItemDecoration(selectConfig.getColumnCount(), dp(5), dp(5), false)); + if (mRecyclerView.getItemAnimator() instanceof SimpleItemAnimator) { + ((SimpleItemAnimator) mRecyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + mRecyclerView.getItemAnimator().setChangeDuration(0);// 通过设置动画执行时间为0来解决闪烁问题 + } + mRecyclerView.setLayoutManager(layoutManager); + mRecyclerView.setAdapter(mAdapter); + } + + /** + * 选择图片文件夹 + * + * @param position 位置 + */ + private void selectImageFromSet(final int position, boolean isTransit) { + currentImageSet = imageSets.get(position); + if (isTransit) { + toggleFolderList(); + } + for (ImageSet set1 : imageSets) { + set1.isSelected = false; + } + currentImageSet.isSelected = true; + mImageSetAdapter.notifyDataSetChanged(); + if (currentImageSet.isAllMedia()) { + if (selectConfig.isShowCameraInAllMedia()) { + selectConfig.setShowCamera(true); + } + } else { + if (selectConfig.isShowCameraInAllMedia()) { + selectConfig.setShowCamera(false); + } + } + loadMediaItemsFromSet(currentImageSet); + } + + + /** + * 显示或隐藏图片文件夹选项列表 + */ + @Override + protected void toggleFolderList() { + if (mFolderListRecyclerView.getVisibility() == View.GONE) { + controllerViewOnTransitImageSet(true); + v_masker.setVisibility(View.VISIBLE); + mFolderListRecyclerView.setVisibility(View.VISIBLE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(mContext, + uiConfig.isShowFromBottom() ? R.anim.picker_show2bottom : R.anim.picker_anim_in)); + } else { + controllerViewOnTransitImageSet(false); + v_masker.setVisibility(View.GONE); + mFolderListRecyclerView.setVisibility(View.GONE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(mContext, + uiConfig.isShowFromBottom() ? R.anim.picker_hide2bottom : R.anim.picker_anim_up)); + } + } + + @Override + public void onClick(@NonNull View v) { + if (onDoubleClick()) { + return; + } + if (v == v_masker) { + toggleFolderList(); + } + } + + @Override + protected void loadMediaSetsComplete(@Nullable List imageSetList) { + if (imageSetList == null || imageSetList.size() == 0 || + (imageSetList.size() == 1 && imageSetList.get(0).count == 0)) { + tip(getString(R.string.picker_str_tip_media_empty)); + return; + } + this.imageSets = imageSetList; + mImageSetAdapter.refreshData(imageSets); + selectImageFromSet(0, false); + } + + @Override + protected void loadMediaItemsComplete(ImageSet set) { + this.imageItems = set.imageItems; + controllerViewOnImageSetSelected(set); + mAdapter.refreshData(imageItems); + } + + @Override + protected void refreshAllVideoSet(ImageSet allVideoSet) { + if (allVideoSet != null && allVideoSet.imageItems != null + && allVideoSet.imageItems.size() > 0 + && !imageSets.contains(allVideoSet)) { + imageSets.add(1, allVideoSet); + mImageSetAdapter.refreshData(imageSets); + } + } + + @Override + public void onTakePhotoResult(@NonNull ImageItem imageItem) { + //剪裁模式下,直接跳转剪裁页面 + if (selectConfig.getSelectMode() == SelectMode.MODE_CROP) { + intentCrop(imageItem); + return; + } + //单选模式下,直接回调出去 + if (selectConfig.getSelectMode() == SelectMode.MODE_SINGLE) { + notifyOnSingleImagePickComplete(imageItem); + return; + } + //将拍照返回的imageItem手动添加到第一个item上并选中 + addItemInImageSets(imageSets, imageItems, imageItem); + mAdapter.refreshData(imageItems); + mImageSetAdapter.refreshData(imageSets); + onCheckItem(imageItem, PickerItemDisableCode.NORMAL); + } + + @Override + public boolean onBackPressed() { + if (mFolderListRecyclerView != null && mFolderListRecyclerView.getVisibility() == View.VISIBLE) { + toggleFolderList(); + return true; + } + if (presenter != null && presenter.interceptPickerCancel(getWeakActivity(), selectList)) { + return true; + } + PickerErrorExecutor.executeError(onImagePickCompleteListener, PickerError.CANCEL.getCode()); + return false; + } + + + @Override + public void onClickItem(@NonNull ImageItem item, int position, int disableItemCode) { + position = selectConfig.isShowCamera() ? position - 1 : position; + //拍照 + if (position < 0 && selectConfig.isShowCamera()) { + if (!presenter.interceptCameraClick(getWeakActivity(), this)) { + checkTakePhotoOrVideo(); + } + return; + } + + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, false)) { + return; + } + + mRecyclerView.setTag(item); + + //剪裁模式下,直接跳转剪裁 + if (selectConfig.getSelectMode() == SelectMode.MODE_CROP) { + if (item.isGif() || item.isVideo()) { + notifyOnSingleImagePickComplete(item); + } else { + intentCrop(item); + } + return; + } + + //检测是否拦截了item点击 + if (!mAdapter.isPreformClick() && presenter.interceptItemClick(getWeakActivity(), item, selectList, imageItems, + selectConfig, mAdapter, false, this)) { + return; + } + + //如果当前是视频,且视频只能单选,且单选情况下自动回调,则执行回调 + if (item.isVideo() && selectConfig.isVideoSinglePickAndAutoComplete()) { + notifyOnSingleImagePickComplete(item); + return; + } + + //如果当前是单选模式,且单选模式下点击item直接回调,则直接回调 + if (selectConfig.getMaxCount() <= 1 && selectConfig.isSinglePickAutoComplete()) { + notifyOnSingleImagePickComplete(item); + return; + } + + //如果当前是视频,且不支持视频预览,则拦截掉点击 + if (item.isVideo() && !selectConfig.isCanPreviewVideo()) { + tip(getActivity().getString(R.string.picker_str_tip_cant_preview_video)); + return; + } + + //如果开启了预览,则直接跳转预览 + if (selectConfig.isPreview()) { + intentPreview(true, position); + } + } + + @Override + public void onCheckItem(ImageItem imageItem, int disableItemCode) { + if (selectConfig.getSelectMode() == SelectMode.MODE_SINGLE + && selectConfig.getMaxCount() == 1 + && selectList != null && selectList.size() > 0) { + if (selectList.contains(imageItem)) { + selectList.clear(); + } else { + selectList.clear(); + selectList.add(imageItem); + } + } else { + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, true)) { + return; + } + + //检测是否拦截了item点击 + if (!mAdapter.isPreformClick() && presenter.interceptItemClick(getWeakActivity(), imageItem, selectList, imageItems, + selectConfig, mAdapter, true, this)) { + return; + } + + //如果当前选中列表包含此item,则移除,否则添加 + if (selectList.contains(imageItem)) { + selectList.remove(imageItem); + } else { + selectList.add(imageItem); + } + } + mAdapter.notifyDataSetChanged(); + refreshCompleteState(); + } + + /** + * 跳转剪裁页面 + * + * @param imageItem 图片信息 + */ + private void intentCrop(ImageItem imageItem) { + ImagePicker.crop(getActivity(), presenter, selectConfig, imageItem, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + selectList.clear(); + selectList.addAll(items); + mAdapter.notifyDataSetChanged(); + notifyPickerComplete(); + } + }); + } + + /** + * 跳转预览 + * + * @param position 默认选中的index + */ + @Override + protected void intentPreview(boolean isClickItem, int position) { + if (!isClickItem && (selectList == null || selectList.size() == 0)) { + return; + } + MultiImagePreviewActivity.intent(getActivity(), isClickItem ? currentImageSet : null, + selectList, selectConfig, presenter, position, new MultiImagePreviewActivity.PreviewResult() { + @Override + public void onResult(ArrayList mImageItems, boolean isCancel) { + if (isCancel) { + reloadPickerWithList(mImageItems); + } else { + selectList.clear(); + selectList.addAll(mImageItems); + mAdapter.notifyDataSetChanged(); + notifyPickerComplete(); + } + } + }); + } + + /** + * 刷新选中图片列表,执行回调,退出页面 + */ + @Override + protected void notifyPickerComplete() { + if (presenter == null || presenter.interceptPickerCompleteClick(getWeakActivity(), selectList, selectConfig)) { + return; + } + if (onImagePickCompleteListener != null) { + if (getActivity() != null) { + loadingDialog = new LoadingDialog(); + loadingDialog.build(getActivity()); + loadingDialog.show(); + } + String temp = MediaUtils.getTempFilePath(); + new Thread(() -> { + for (int i = 0; i < selectList.size(); i++) { + ImageItem imageItem = selectList.get(i); + imageItem.isOriginalImage = ImagePicker.isOriginalImage; + imageItem.path = MediaUtils.copyUriToLocalMedia(getActivity(), imageItem, temp); + //重新获取图片宽高(从图库获取的宽高有错误情况) + if (imageItem.isImage() || imageItem.isGif()) { + int[] imageWidthHeight = PBitmapUtils.getImageWidthHeight(imageItem.path); + Timber.d("MultiImagePickerFragment width:" + imageWidthHeight[0] + " height:" + imageWidthHeight[1]); + imageItem.width = imageWidthHeight[0]; + imageItem.height = imageWidthHeight[1]; + } + } + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + if (loadingDialog != null) { + loadingDialog.dismiss(); + loadingDialog = null; + } + onImagePickCompleteListener.onImagePickComplete(selectList); + }); + } + }).start(); + } + } + + @Override + protected BaseSelectConfig getSelectConfig() { + return selectConfig; + } + + @Override + protected IPickerPresenter getPresenter() { + return presenter; + } + + @Override + protected PickerUiConfig getUiConfig() { + return uiConfig; + } + + @Override + public void onDestroy() { + uiConfig.setPickerUiProvider(null); + uiConfig = null; + presenter = null; + super.onDestroy(); + } + + @Override + public void reloadPickerWithList(List selectedList) { + selectList.clear(); + selectList.addAll(selectedList); + mAdapter.refreshData(imageItems); + refreshCompleteState(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java new file mode 100644 index 0000000..a756fdf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java @@ -0,0 +1,382 @@ +package com.remax.visualnovel.widget.imagepicker.activity.preview; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_CURRENT_INDEX; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.utils.StatusBarUtils; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.MediaItemsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.PreviewControllerView; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; + + +/** + * Description: 预览页面,其中包含编辑预览和普通预览 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImagePreviewActivity extends AppCompatActivity implements MediaItemsDataSource.MediaItemProvider { + static ImageSet currentImageSet; + public static final String INTENT_KEY_SELECT_LIST = "selectList"; + private ViewPager mViewPager; + private ArrayList mSelectList; + private ArrayList mImageList; + private int mCurrentItemPosition = 0; + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + private PickerUiConfig uiConfig; + private WeakReference activityWeakReference; + private DialogInterface dialogInterface; + private PreviewControllerView controllerView; + private TouchImageAdapter touchImageAdapter; + + /** + * 预览回调 + */ + public interface PreviewResult { + void onResult(ArrayList imageItems, boolean isCancel); + } + + /** + * 跳转预览 + * + * @param activity 当前activity + * @param imageSet 当前预览的文件夹信息 + * @param selectList 选中列表 + * @param selectConfig 配置信息 + * @param presenter presenter + * @param position 默认选中项 + * @param result 预览回调 + */ + public static void intent(Activity activity, ImageSet imageSet, + ArrayList selectList, + MultiSelectConfig selectConfig, + IPickerPresenter presenter, + int position, + final PreviewResult result) { + if (activity == null || selectList == null || selectConfig == null + || presenter == null || result == null) { + return; + } + if (imageSet != null) { + currentImageSet = imageSet.copy(); + } + Intent intent = new Intent(activity, MultiImagePreviewActivity.class); + intent.putExtra(INTENT_KEY_SELECT_LIST, selectList); + intent.putExtra(INTENT_KEY_SELECT_CONFIG, selectConfig); + intent.putExtra(INTENT_KEY_PRESENTER, presenter); + intent.putExtra(INTENT_KEY_CURRENT_INDEX, position); + PLauncher.init(activity).startActivityForResult(intent, (resultCode, data) -> { + if (data != null && data.hasExtra(ImagePicker.INTENT_KEY_PICKER_RESULT)) { + ArrayList mList = (ArrayList) data.getSerializableExtra(ImagePicker.INTENT_KEY_PICKER_RESULT); + if (mList != null) { + result.onResult(mList, resultCode == RESULT_CANCELED); + } + } + }); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + activityWeakReference = new WeakReference(this); + if (isIntentDataFailed()) { + finish(); + return; + } + PickerActivityManager.addActivity(this); + setContentView(R.layout.picker_activity_preview); + mViewPager = findViewById(R.id.viewpager); + setUI(); + loadMediaPreviewList(); + } + + /** + * @return 跳转数据是否合法 + */ + private boolean isIntentDataFailed() { + if (getIntent() == null || !getIntent().hasExtra(INTENT_KEY_SELECT_CONFIG) + || !getIntent().hasExtra(INTENT_KEY_PRESENTER)) { + return true; + } + selectConfig = (MultiSelectConfig) getIntent().getSerializableExtra(INTENT_KEY_SELECT_CONFIG); + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_PRESENTER); + mCurrentItemPosition = getIntent().getIntExtra(INTENT_KEY_CURRENT_INDEX, 0); + ArrayList list = (ArrayList) getIntent().getSerializableExtra(INTENT_KEY_SELECT_LIST); + if (list == null || presenter == null) { + return true; + } + mSelectList = new ArrayList(list); + uiConfig = presenter.getUiConfig(activityWeakReference.get()); + return false; + } + + /** + * 执行返回回调 + * + * @param isClickComplete 是否是选中 + */ + private void notifyCallBack(boolean isClickComplete) { + Intent intent = new Intent(); + intent.putExtra(ImagePicker.INTENT_KEY_PICKER_RESULT, mSelectList); + setResult(isClickComplete ? ImagePicker.REQ_PICKER_RESULT_CODE : RESULT_CANCELED, intent); + finish(); + } + + @Override + public void onBackPressed() { + notifyCallBack(false); + } + + /** + * 加载媒体文件夹 + */ + private void loadMediaPreviewList() { + if (currentImageSet == null) { + if (mSelectList != null && mSelectList.size() > 0) { + initViewPager(mSelectList); + } + } else if (currentImageSet.imageItems != null + && currentImageSet.imageItems.size() > 0 + && currentImageSet.imageItems.size() >= currentImageSet.count) { + initViewPager(currentImageSet.imageItems); + } else { + //从媒体库重新扫描 + dialogInterface = getPresenter().showProgressDialog(this, ProgressSceneEnum.loadMediaItem); + ImagePicker.provideMediaItemsFromSet(this, currentImageSet, + selectConfig.getMimeTypes(), this); + } + } + + @Override + public void providerMediaItems(ArrayList imageItems, ImageSet allVideoSet) { + if (dialogInterface != null) { + dialogInterface.dismiss(); + } + initViewPager(imageItems); + } + + /** + * 过滤掉视频 + * + * @param list 所有数据源 + * @return 过滤后的数据源 + */ + private ArrayList filterVideo(ArrayList list) { + if (selectConfig.isCanPreviewVideo()) { + mImageList = new ArrayList<>(list); + return mImageList; + } + mImageList = new ArrayList<>(); + int videoCount = 0; + int nowPosition = 0; + int i = 0; + for (ImageItem imageItem : list) { + if (!imageItem.isVideo() && !imageItem.isGif()) { + mImageList.add(imageItem); + } else { + videoCount++; + } + if (i == mCurrentItemPosition) { + nowPosition = i - videoCount; + } + i++; + } + mCurrentItemPosition = nowPosition; + return mImageList; + } + + /** + * 初始化标题栏 + */ + private void setUI() { + if (uiConfig != null) { + mViewPager.setBackgroundColor(uiConfig.getPreviewBackgroundColor()); + controllerView = uiConfig.getPickerUiProvider().getPreviewControllerView(activityWeakReference.get()); + if (controllerView == null) { + controllerView = new com.remax.visualnovel.widget.imagepicker.views.wrapper.PreviewControllerView(this); + } +// controllerView.setStatusBar(); + StatusBarUtils.INSTANCE.setColor(this, ContextCompat.getColor(this, R.color.glo_color_grey_100)); + StatusBarUtils.INSTANCE.setStatusBarAndNavBarIsLight(this, false); + controllerView.initData(selectConfig, presenter, uiConfig, mSelectList); + if (controllerView.getCompleteView() != null) { + controllerView.getCompleteView().setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (PViewSizeUtils.onDoubleClick()) { + return; + } + notifyCallBack(true); + } + }); + } + FrameLayout mPreviewPanel = findViewById(R.id.mPreviewPanel); + mPreviewPanel.addView(controllerView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + } + } + + /** + * 初始化viewpager + */ + private void initViewPager(ArrayList sourceList) { + mImageList = filterVideo(sourceList); + if (mImageList == null || mImageList.size() == 0) { + getPresenter().tip(this, getString(R.string.picker_str_preview_empty)); + finish(); + return; + } + if (mCurrentItemPosition < 0) { + mCurrentItemPosition = 0; + } + TouchImageAdapter mAdapter = new TouchImageAdapter(getSupportFragmentManager(), mImageList); + mViewPager.setAdapter(mAdapter); + mViewPager.setOffscreenPageLimit(1); + mViewPager.setCurrentItem(mCurrentItemPosition, false); + if (mCurrentItemPosition >= mImageList.size()) { + return; + } + ImageItem item = mImageList.get(mCurrentItemPosition); + controllerView.onPageSelected(mCurrentItemPosition, item, mImageList.size()); + mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + mCurrentItemPosition = position; + if (mCurrentItemPosition >= mImageList.size()) { + return; + } + ImageItem item = mImageList.get(mCurrentItemPosition); + controllerView.onPageSelected(mCurrentItemPosition, item, mImageList.size()); + } + + @Override + public void onPageScrollStateChanged(int state) { + } + }); + } + + /** + * 预览列表点击 + * + * @param imageItem 当前图片 + */ + public void onPreviewItemClick(ImageItem imageItem) { + mViewPager.setCurrentItem(mImageList.indexOf(imageItem), false); + } + + public IPickerPresenter getPresenter() { + return presenter; + } + + public PreviewControllerView getControllerView() { + return controllerView; + } + + static class TouchImageAdapter extends FragmentStatePagerAdapter { + private ArrayList imageItems; + + TouchImageAdapter(FragmentManager fm, ArrayList imageItems) { + super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.imageItems = imageItems; + if (this.imageItems == null) { + this.imageItems = new ArrayList<>(); + } + } + + @Override + public int getCount() { + return imageItems.size(); + } + + @NonNull + @Override + public Fragment getItem(int position) { + return SinglePreviewFragment.newInstance(imageItems.get(position)); + } + } + + @Override + public void finish() { + super.finish(); + PickerActivityManager.removeActivity(this); + if (currentImageSet != null && currentImageSet.imageItems != null) { + currentImageSet.imageItems.clear(); + currentImageSet = null; + } + } + + public static class SinglePreviewFragment extends Fragment { + static final String KEY_URL = "key_url"; + private ImageItem imageItem; + + static SinglePreviewFragment newInstance(ImageItem imageItem) { + SinglePreviewFragment fragment = new SinglePreviewFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(SinglePreviewFragment.KEY_URL, imageItem); + fragment.setArguments(bundle); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle bundle = getArguments(); + if (bundle == null) { + return; + } + imageItem = (ImageItem) bundle.getSerializable(KEY_URL); + } + + PreviewControllerView getControllerView() { + return ((MultiImagePreviewActivity) getActivity()).getControllerView(); + } + + IPickerPresenter getPresenter() { + return ((MultiImagePreviewActivity) getActivity()).getPresenter(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return getControllerView().getItemView(this, imageItem, getPresenter()); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java new file mode 100644 index 0000000..7390df9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java @@ -0,0 +1,269 @@ +package com.remax.visualnovel.widget.imagepicker.activity.singlecrop; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.os.Parcelable; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.AppCompatImageView; + +import com.remax.visualnovel.extension.ViewExtKt; +import com.remax.visualnovel.R; +import com.remax.visualnovel.utils.StatusBarUtils; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropConfigParcelable; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.PickerActivityCallBack; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.helper.DetailImageLoadHelper; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.SingleCropControllerView; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + +import java.io.File; +import java.util.ArrayList; + + +/** + * Description: 图片剪裁页面 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class SingleCropActivity extends AppCompatActivity { + public static final String INTENT_KEY_CURRENT_IMAGE_ITEM = "currentImageItem"; + private CropImageView cropView; + private AppCompatImageView taskImageView; + private CropConfigParcelable cropConfig; + private IPickerPresenter presenter; + private ImageItem currentImageItem; + + /** + * 跳转单图剪裁 + * + * @param activity 跳转的activity + * @param presenter IPickerPresenter + * @param cropConfig 剪裁配置 + * @param path 需要剪裁的图片的原始路径,可以为Uri相对路径 + * @param listener 剪裁回调 + */ + public static void intentCrop(Activity activity, + IPickerPresenter presenter, + CropConfig cropConfig, + String path, + final OnImagePickCompleteListener listener) { + intentCrop(activity, presenter, cropConfig, ImageItem.withPath(activity, path), listener); + } + + public static void intentCrop(Activity activity, + IPickerPresenter presenter, + CropConfig cropConfig, + ImageItem item, + final OnImagePickCompleteListener listener) { + Intent intent = new Intent(activity, SingleCropActivity.class); + intent.putExtra(INTENT_KEY_PRESENTER, presenter); + intent.putExtra(INTENT_KEY_SELECT_CONFIG, cropConfig.getCropInfo()); + intent.putExtra(INTENT_KEY_CURRENT_IMAGE_ITEM, (Parcelable) item); + PLauncher.init(activity).startActivityForResult(intent, PickerActivityCallBack.create(listener)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getIntent() == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_PRESENTER); + cropConfig = getIntent().getParcelableExtra(INTENT_KEY_SELECT_CONFIG); + if (presenter == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + if (cropConfig == null) { + PickerErrorExecutor.executeError(this, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return; + } + currentImageItem = getIntent().getParcelableExtra(INTENT_KEY_CURRENT_IMAGE_ITEM); + if (currentImageItem == null || currentImageItem.isEmpty()) { + PickerErrorExecutor.executeError(this, PickerError.CROP_URL_NOT_FOUND.getCode()); + return; + } + + if (new File(currentImageItem.path).length() / 1024 >= 5 * 1024) { + PickerErrorExecutor.executeError(this, PickerError.IMG_SIZE_ERROR.getCode()); + Toast.makeText(this, PickerError.IMG_SIZE_ERROR.getMessage(), Toast.LENGTH_SHORT).show(); + return; + } + + PickerActivityManager.addActivity(this); + setContentView(cropConfig.isSingleCropCutNeedTop() ? R.layout.picker_activity_crop_cover : R.layout.picker_activity_crop); + + //初始化剪裁view + cropView = findViewById(R.id.cropView); + cropView.setMaxScale(3.0f); + cropView.setRotateEnable(true); + cropView.enable(); + cropView.setBounceEnable(!cropConfig.isGap()); + cropView.setCropMargin(cropConfig.getCropRectMargin()); + cropView.setCircle(cropConfig.isCircle()); + cropView.setCropRatio(cropConfig.getCropRatioX(), cropConfig.getCropRatioY()); + + taskImageView = findViewById(R.id.taskFrame); + int taskFrame = cropConfig.getCropTaskFrame(); + taskImageView.setVisibility(View.GONE); + if (taskFrame != 0) { + taskImageView.setVisibility(View.VISIBLE); + ViewExtKt.setSize(taskImageView, taskFrame, taskFrame); + } + + //恢复上一次剪裁属性 + if (cropConfig.getCropRestoreInfo() != null) { + cropView.setRestoreInfo(cropConfig.getCropRestoreInfo()); + } + + //加载图片 + DetailImageLoadHelper.displayDetailImage(true, cropView, presenter, currentImageItem); + setControllerView(); + StatusBarUtils.INSTANCE.setTransparent(this); + } + + /** + * 设置剪裁控制器View + */ + private void setControllerView() { + FrameLayout mCropPanel = findViewById(R.id.mCropPanel); + PickerUiConfig uiConfig = presenter.getUiConfig(this); + SingleCropControllerView cropControllerView = uiConfig.getPickerUiProvider() + .getSingleCropControllerView(this); + mCropPanel.addView(cropControllerView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + cropControllerView.setCropViewParams(cropView, (ViewGroup.MarginLayoutParams) cropView.getLayoutParams()); + cropControllerView.getCompleteView().setOnClickListener(v -> { + if (PViewSizeUtils.onDoubleClick()) { + return; + } + generateCropFile("crop_" + System.currentTimeMillis()); + }); + } + + + /** + * 剪裁完成 + * + * @param cropUrl 剪裁生成的绝对路径 + */ + private void cropComplete(String cropUrl) { + //如果正在编辑中... + if (cropView.isEditing()) { + return; + } + //剪裁异常 + if (cropUrl == null || cropUrl.length() == 0 || cropUrl.startsWith("Exception:")) { + presenter.tip(this, getString(R.string.picker_str_tip_singleCrop_error)); + cropView.setCropRatio(cropConfig.getCropRatioX(), cropConfig.getCropRatioY()); + return; + } + //回调剪裁数据 + // currentImageItem.path = cropUrl; + currentImageItem.mimeType = cropConfig.isNeedPng() ? MimeType.PNG.toString() : MimeType.JPEG.toString(); + currentImageItem.width = cropView.getCropWidth(); + currentImageItem.height = cropView.getCropHeight(); + currentImageItem.setCropUrl(cropUrl); + currentImageItem.setCropRestoreInfo(cropView.getInfo()); + notifyOnImagePickComplete(currentImageItem); + } + + + private DialogInterface dialogInterface; + + /** + * 生成剪裁文件 + * + * @param fileName 图片名称 + */ + public void generateCropFile(final String fileName) { + dialogInterface = presenter.showProgressDialog(this, ProgressSceneEnum.crop); + if (cropConfig.isGap() && !cropConfig.isCircle()) { + cropView.setBackgroundColor(cropConfig.getCropGapBackgroundColor()); + } + currentImageItem.displayName = fileName; + new Thread(() -> { + Bitmap bitmap; + if (cropConfig.isGap()) { + bitmap = cropView.generateCropBitmapFromView(cropConfig.getCropGapBackgroundColor()); + } else { + bitmap = cropView.generateCropBitmap(); + } + final String url = saveBitmapToFile(bitmap, fileName); + runOnUiThread(() -> { + if (dialogInterface != null && !SingleCropActivity.this.isDestroyed()) { + dialogInterface.dismiss(); + } + cropComplete(url); + }); + }).start(); + } + + /** + * 保存bitmap到本地磁盘 + * + * @param bitmap 图片bitmap + * @param fileName 图片名字 + */ + private String saveBitmapToFile(final Bitmap bitmap, final String fileName) { + final String cropUrl; + Bitmap.CompressFormat format = cropConfig.isNeedPng() ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG; + if (cropConfig.isSaveInDCIM()) { + cropUrl = PBitmapUtils.saveBitmapToDCIM(SingleCropActivity.this, bitmap, fileName, format).toString(); + } else { + cropUrl = PBitmapUtils.saveBitmapToFile(SingleCropActivity.this, bitmap, fileName, format); + } + return cropUrl; + } + + + /** + * 回调当前剪裁图片信息 + * + * @param imageItem 剪裁图片信息 + */ + private void notifyOnImagePickComplete(ImageItem imageItem) { + ArrayList list = new ArrayList<>(); + list.add(imageItem); + Intent intent = new Intent(); + intent.putExtra(ImagePicker.INTENT_KEY_PICKER_RESULT, list); + setResult(ImagePicker.REQ_PICKER_RESULT_CODE, intent); + finish(); + } + + @Override + public void finish() { + super.finish(); + if (dialogInterface != null) { + dialogInterface.dismiss(); + } + PickerActivityManager.removeActivity(this); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java new file mode 100644 index 0000000..e6288a8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java @@ -0,0 +1,117 @@ +package com.remax.visualnovel.widget.imagepicker.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.preview.MultiImagePreviewActivity; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper.ItemTouchHelperAdapter; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.widget.ShowTypeImageView; + +import java.util.ArrayList; +import java.util.Collections; + +/** + * Time: 2019/7/23 10:43 + * Author:ypx + * Description: 多选预览adapter + */ +public class MultiPreviewAdapter extends RecyclerView.Adapter implements ItemTouchHelperAdapter { + private final ArrayList previewList; + private Context context; + private final IPickerPresenter presenter; + private ImageItem previewImageItem; + + @SuppressLint("NotifyDataSetChanged") + public void setPreviewImageItem(ImageItem previewImageItem) { + this.previewImageItem = previewImageItem; + notifyDataSetChanged(); + } + + public MultiPreviewAdapter(ArrayList previewList, IPickerPresenter presenter) { + this.previewList = previewList; + this.presenter = presenter; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + context = parent.getContext(); + ShowTypeImageView imageView = new ShowTypeImageView(context); + ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(dp(48), dp(48)); + params.topMargin = dp(36); + imageView.setLayoutParams(params); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + return new ViewHolder(imageView); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, final int position) { + final ImageItem imageItem = previewList.get(position); + boolean isSelect = previewImageItem != null && previewImageItem.equals(imageItem); + holder.imageView.setSelect(isSelect, ImagePicker.getThemeColor()); + holder.imageView.setTypeFromImage(imageItem); + holder.imageView.setOnClickListener(v -> { + if (context instanceof MultiImagePreviewActivity) { + ((MultiImagePreviewActivity) context).onPreviewItemClick(imageItem); + } + }); + presenter.displayImage(holder.imageView, imageItem, 0, true); + } + + @Override + public int getItemCount() { + return previewList.size(); + } + + public int dp(float dp) { + if (context == null) { + return 0; + } + float density = context.getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + @Override + public boolean onItemMove(int fromPosition, int toPosition) { + try { + if (null == previewList + || fromPosition >= previewList.size() + || toPosition >= previewList.size()) { + return true; + } + Collections.swap(previewList, fromPosition, toPosition); + notifyItemMoved(fromPosition, toPosition); + } catch (Exception e) { + e.printStackTrace(); + } + return true; + } + + @Override + public void onItemDismiss(int position) { + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + static class ViewHolder extends RecyclerView.ViewHolder { + private final ShowTypeImageView imageView; + + ViewHolder(@NonNull View itemView) { + super(itemView); + this.imageView = (ShowTypeImageView) itemView; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java new file mode 100644 index 0000000..aa4c0ff --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java @@ -0,0 +1,105 @@ +package com.remax.visualnovel.widget.imagepicker.adapter; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerFolderItemView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.FolderItemView; + +import java.util.ArrayList; +import java.util.List; + + +/** + * Time: 2018/4/6 10:47 + * Author:yangpeixing + * Description: 文件夹adapter + */ +public class PickerFolderAdapter extends RecyclerView.Adapter { + private List mImageSets = new ArrayList<>(); + private IPickerPresenter presenter; + private PickerUiConfig uiConfig; + + public PickerFolderAdapter(IPickerPresenter presenter, PickerUiConfig uiConfig) { + this.presenter = presenter; + this.uiConfig = uiConfig; + } + + public void refreshData(List folders) { + mImageSets.clear(); + mImageSets.addAll(folders); + notifyDataSetChanged(); + } + + private ImageSet getItem(int i) { + return mImageSets.get(i); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.picker_item_root, parent, false); + return new ViewHolder(view, uiConfig); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, @SuppressLint("RecyclerView") final int position) { + ImageSet imageSet = getItem(position); + PickerFolderItemView pickerFolderItemView = holder.pickerFolderItemView; + pickerFolderItemView.displayCoverImage(imageSet, presenter); + pickerFolderItemView.loadItem(imageSet); + pickerFolderItemView.setOnClickListener(v -> { + if (folderSelectResult != null) { + folderSelectResult.folderSelected(getItem(position), position); + } + }); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public int getItemCount() { + return mImageSets.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + private PickerFolderItemView pickerFolderItemView; + + ViewHolder(View view, PickerUiConfig uiConfig) { + super(view); + pickerFolderItemView = uiConfig.getPickerUiProvider().getFolderItemView(view.getContext()); + if (pickerFolderItemView == null) { + pickerFolderItemView = new FolderItemView(view.getContext()); + } + FrameLayout layout = itemView.findViewById(R.id.mRoot); + int height = pickerFolderItemView.getItemHeight(); + layout.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + height > 0 ? height : ViewGroup.LayoutParams.WRAP_CONTENT)); + layout.removeAllViews(); + layout.addView(pickerFolderItemView); + } + } + + private FolderSelectResult folderSelectResult; + + public void setFolderSelectResult(FolderSelectResult folderSelectResult) { + this.folderSelectResult = folderSelectResult; + } + + public interface FolderSelectResult { + void folderSelected(ImageSet set, int pos); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java new file mode 100644 index 0000000..9b7ea05 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java @@ -0,0 +1,240 @@ +package com.remax.visualnovel.widget.imagepicker.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerItemView; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Description: 多选adapter + *

+ * Author: yangpeixing on 2018/4/6 10:32 + * Date: 2019/2/21 + */ +public class PickerItemAdapter extends RecyclerView.Adapter { + private static final int ITEM_TYPE_CAMERA = 0; + private static final int ITEM_TYPE_NORMAL = 1; + private List images; + //选中图片列表 + private final ArrayList selectList; + private final BaseSelectConfig selectConfig; + private final IPickerPresenter presenter; + private final PickerUiConfig uiConfig; + private boolean isPreformClick = false; + + public PickerItemAdapter(ArrayList selectList, + List images, + BaseSelectConfig selectConfig, + IPickerPresenter presenter, + PickerUiConfig uiConfig) { + this.images = images; + this.selectList = selectList; + this.selectConfig = selectConfig; + this.presenter = presenter; + this.uiConfig = uiConfig; + } + + /** + * 模拟执行选中(取消选中)操作 + * + * @param imageItem 当前item + */ + public void preformCheckItem(ImageItem imageItem) { + if (onActionResult != null) { + isPreformClick = true; + onActionResult.onCheckItem(imageItem, PickerItemDisableCode.NORMAL); + } + } + + /** + * 模拟执行点击操作 + * + * @param imageItem 当前item + * @param position 当前item的position + */ + public void preformClickItem(ImageItem imageItem, int position) { + if (onActionResult != null) { + isPreformClick = true; + onActionResult.onClickItem(imageItem, position, PickerItemDisableCode.NORMAL); + } + } + + @NonNull + @Override + public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ItemViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.picker_item_root, parent, false), + viewType == ITEM_TYPE_CAMERA, selectConfig, presenter, uiConfig); + } + + + @Override + public void onBindViewHolder(@NonNull final ItemViewHolder viewHolder, @SuppressLint("RecyclerView") final int position) { + int itemViewType = getItemViewType(position); + final ImageItem imageItem = getItem(position); + if (itemViewType == ITEM_TYPE_CAMERA || imageItem == null) { + viewHolder.itemView.setOnClickListener(view -> preformClickItem(null, -1)); + return; + } + PickerItemView pickerItemView = viewHolder.pickerItemView; + final int index = selectConfig.isShowCamera() ? position - 1 : position; + pickerItemView.setPosition(index); + pickerItemView.setAdapter(this); + pickerItemView.initItem(imageItem, presenter, selectConfig); + + int indexOfSelectList = selectList.indexOf(imageItem); + boolean isContainsThisItem = indexOfSelectList >= 0; + final int finalDisableCode = PickerItemDisableCode.getItemDisableCode(imageItem, selectConfig, + selectList, isContainsThisItem); + if (pickerItemView.getCheckBoxView() != null) { + pickerItemView.getCheckBoxView().setOnClickListener(view -> { + if (onActionResult != null) { + if (new File(imageItem.path).exists()) { + isPreformClick = false; + onActionResult.onCheckItem(imageItem, finalDisableCode); + } else { + Toast.makeText(CommonApplicationProxy.INSTANCE.getApplication(), CommonApplicationProxy.INSTANCE.getApplication().getString(R.string.file_not_found_hint), + Toast.LENGTH_SHORT).show(); + } + } + }); + } + + pickerItemView.setOnClickListener(view -> { + if (onActionResult != null) { + isPreformClick = false; + onActionResult.onClickItem(imageItem, position, finalDisableCode); + } + }); + + pickerItemView.enableItem(imageItem, indexOfSelectList >= 0, indexOfSelectList,selectConfig.getMaxCount() == selectList.size()); + if (finalDisableCode != PickerItemDisableCode.NORMAL) { + pickerItemView.disableItem(imageItem, finalDisableCode); + } + } + + @Override + public int getItemViewType(int position) { + if (selectConfig.isShowCamera()) { + return position == 0 ? ITEM_TYPE_CAMERA : ITEM_TYPE_NORMAL; + } + return ITEM_TYPE_NORMAL; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemCount() { + return selectConfig.isShowCamera() ? images.size() + 1 : images.size(); + } + + private ImageItem getItem(int position) { + if (selectConfig.isShowCamera()) { + if (position == 0) { + return null; + } + return images.get(position - 1); + } else { + return images.get(position); + } + } + + public void refreshData(List items) { + if (items != null && items.size() > 0) { + images = items; + } + notifyDataSetChanged(); + } + + static class ItemViewHolder extends RecyclerView.ViewHolder { + private final PickerItemView pickerItemView; + private final Context context; + + ItemViewHolder(@NonNull View itemView, boolean isCamera, BaseSelectConfig selectConfig, IPickerPresenter presenter, PickerUiConfig uiConfig) { + super(itemView); + context = itemView.getContext(); + FrameLayout layout = itemView.findViewById(R.id.mRoot); + int width = (getScreenWidth() - dp(8)) / selectConfig.getColumnCount(); + PViewSizeUtils.setViewSize(layout, width, 1.00f); + + pickerItemView = uiConfig.getPickerUiProvider().getItemView(context); + layout.removeAllViews(); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + params.bottomMargin = dp(2); + params.topMargin = dp(2); + params.rightMargin = dp(2); + params.leftMargin = dp(2); + if (isCamera) { + layout.addView(pickerItemView.getCameraView(selectConfig, presenter), params); + } else { + layout.addView(pickerItemView, params); + } + } + + int getScreenWidth() { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.widthPixels; + } + + int dp(int dp) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + (float) dp, context.getResources().getDisplayMetrics()); + } + } + + public boolean isPreformClick() { + return isPreformClick; + } + + private OnActionResult onActionResult; + + public void setOnActionResult(OnActionResult onActionResult) { + this.onActionResult = onActionResult; + } + + public interface OnActionResult { + /** + * 点击操作 + * + * @param imageItem 当前item + * @param position 当前item的position + */ + void onClickItem(ImageItem imageItem, int position, int disableItemCode); + + /** + * 执行选中(取消选中)操作 + * + * @param imageItem 当前item + */ + void onCheckItem(ImageItem imageItem, int disableItemCode); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java new file mode 100644 index 0000000..3e4e8c6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java @@ -0,0 +1,27 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +/** + * Description: 图片剪裁模式 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class ImageCropMode { + /** + * 填充模式,按照图片宽度填充到容器(屏幕)宽度 + */ + public static int CropViewScale_FULL = -5; + /** + * 自适应模式,按照图片高度自适应容器高度 + */ + public static int CropViewScale_FIT = -6; + /** + * imageView图片显示模式 填充 + */ + public static int ImageScale_FILL = -7; + + /** + * imageView图片显示模式 留白 + */ + public static int ImageScale_GAP = -8; +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java new file mode 100644 index 0000000..f876c19 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java @@ -0,0 +1,409 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +import android.app.Activity; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + + +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.Info; + +import java.io.Serializable; + + +/** + * Description: 图片信息 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class ImageItem implements Serializable, Parcelable { + private static final long serialVersionUID = 3429291195776736078L; + //媒体文件ID,可通过此id查询此媒体文件的所有信息 + public long id; + //媒体文件宽 + public int width; + //高 + public int height; + //生成或者更新时间 + public long time; + //时常(仅针对视频) + public long duration; + //文件类型 + public String mimeType; + //更新时间格式化 例如:2019年12月 本周内 等 + public String timeFormat; + //时常格式化 00:00:00 + public String durationFormat; + //是否是视频文件 + private boolean isVideo = false; + //是否是原图 + public boolean isOriginalImage = true; + //文件名 + public String displayName; + + //视频缩略图地址,默认是null,并没有扫描视频缩略图,这里提供此变量便于使用者自己塞入使用 + private String videoImageUri; + // 加入滤镜后的原图图片地址,如果无滤镜返回原图地址,这里提供此变量便于使用者自己app塞入地址使用 + private String imageFilterPath = ""; + + //androidQ上废弃了DATA绝对路径,需要手动拼凑Uri,这里为了兼容大部分项目还没有适配androidQ的情况 + //默认path还是先取绝对路径,取不到或者异常才去取Uri路径 + public String path; + //直接拿到Uri路径,在媒体库里,一定会有Uri路径 + private String uriPath; + // 剪裁后的图片绝对地址(从imageFilterPath 计算出来,已经带了滤镜) + private String cropUrl; + //是否公开 + public boolean openLock; + + //以下是UI上用到的临时变量 + private boolean isSelect = false; + private boolean isPress = false; + private int selectIndex = -1; + private int cropMode = ImageCropMode.ImageScale_FILL; + + private Info cropRestoreInfo; + + public ImageItem() { + } + + public static ImageItem withPath(Context context, String path) { + ImageItem imageItem = new ImageItem(); + imageItem.path = path; + if (imageItem.isUriPath()) { + Uri uri = Uri.parse(path); + imageItem.setUriPath(uri.toString()); + imageItem.mimeType = PBitmapUtils.getMimeTypeFromUri((Activity) context, uri); + if (imageItem.mimeType != null && imageItem.isImage()) { + imageItem.setVideo(MimeType.isVideo(imageItem.mimeType)); + if (imageItem.isImage()) { + int[] size = PBitmapUtils.getImageWidthHeight(context, uri); + imageItem.width = size[0]; + imageItem.height = size[1]; + } + } + } else { + imageItem.mimeType = PBitmapUtils.getMimeTypeFromPath(imageItem.path); + if (imageItem.mimeType != null) { + imageItem.setVideo(MimeType.isVideo(imageItem.mimeType)); + Uri uri; + if (imageItem.isImage()) { + uri = PBitmapUtils.getImageContentUri(context, path); + int[] size = PBitmapUtils.getImageWidthHeight(path); + imageItem.width = size[0]; + imageItem.height = size[1]; + } else { + uri = PBitmapUtils.getVideoContentUri(context, path); + imageItem.duration = PBitmapUtils.getLocalVideoDuration(path); + } + if (uri != null) { + imageItem.setUriPath(uri.toString()); + } + } + } + + return imageItem; + } + + + protected ImageItem(Parcel in) { + id = in.readLong(); + width = in.readInt(); + height = in.readInt(); + time = in.readLong(); + duration = in.readLong(); + mimeType = in.readString(); + timeFormat = in.readString(); + durationFormat = in.readString(); + isVideo = in.readByte() != 0; + videoImageUri = in.readString(); + imageFilterPath = in.readString(); + path = in.readString(); + uriPath = in.readString(); + cropUrl = in.readString(); + isSelect = in.readByte() != 0; + isPress = in.readByte() != 0; + selectIndex = in.readInt(); + cropMode = in.readInt(); + cropRestoreInfo = in.readParcelable(Info.class.getClassLoader()); + isOriginalImage = in.readByte() != 0; + openLock = in.readByte() != 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeInt(width); + dest.writeInt(height); + dest.writeLong(time); + dest.writeLong(duration); + dest.writeString(mimeType); + dest.writeString(timeFormat); + dest.writeString(durationFormat); + dest.writeByte((byte) (isVideo ? 1 : 0)); + dest.writeString(videoImageUri); + dest.writeString(imageFilterPath); + dest.writeString(path); + dest.writeString(uriPath); + dest.writeString(cropUrl); + dest.writeByte((byte) (isSelect ? 1 : 0)); + dest.writeByte((byte) (isPress ? 1 : 0)); + dest.writeInt(selectIndex); + dest.writeInt(cropMode); + dest.writeParcelable(cropRestoreInfo, flags); + dest.writeByte((byte) (isOriginalImage ? 1 : 0)); + dest.writeByte((byte) (openLock ? 1 : 0)); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public ImageItem createFromParcel(Parcel in) { + return new ImageItem(in); + } + + @Override + public ImageItem[] newArray(int size) { + return new ImageItem[size]; + } + }; + + public Info getCropRestoreInfo() { + return cropRestoreInfo; + } + + public void setCropRestoreInfo(Info cropRestoreInfo) { + this.cropRestoreInfo = cropRestoreInfo; + } + + public String getVideoImageUri() { + if (videoImageUri == null || videoImageUri.length() == 0) { + return path; + } + return videoImageUri; + } + + public void setVideoImageUri(String videoImageUri) { + this.videoImageUri = videoImageUri; + } + + public String getImageFilterPath() { + if (imageFilterPath == null || imageFilterPath.length() == 0) { + return path; + } + return imageFilterPath; + } + + public void setImageFilterPath(String imageFilterPath) { + this.imageFilterPath = imageFilterPath; + } + + public boolean isOriginalImage() { + return isOriginalImage; + } + + public void setOriginalImage(boolean originalImage) { + isOriginalImage = originalImage; + } + + public String getLastImageFilterPath() { + return imageFilterPath; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public long getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getDurationFormat() { + return durationFormat; + } + + public void setDurationFormat(String durationFormat) { + this.durationFormat = durationFormat; + } + + public void setVideo(boolean video) { + isVideo = video; + } + + public boolean isGif() { + return MimeType.isGif(mimeType); + } + + public boolean isLongImage() { + return getWidthHeightRatio() > 5 || getWidthHeightRatio() < 0.2; + } + + public boolean isVideo() { + return isVideo; + } + + public boolean isImage() { + return !isVideo; + } + + public int getCropMode() { + return cropMode; + } + + public void setCropMode(int cropMode) { + this.cropMode = cropMode; + } + + public String getCropUrl() { + return cropUrl; + } + + public void setCropUrl(String cropUrl) { + this.cropUrl = cropUrl; + } + + public int getSelectIndex() { + return selectIndex; + } + + public void setSelectIndex(int selectIndex) { + this.selectIndex = selectIndex; + } + + public boolean isPress() { + return isPress; + } + + public void setPress(boolean press) { + isPress = press; + } + + public boolean isSelect() { + return isSelect; + } + + public void setSelect(boolean select) { + isSelect = select; + } + + public String getTimeFormat() { + return timeFormat; + } + + public void setTimeFormat(String timeFormat) { + this.timeFormat = timeFormat; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public boolean isUriPath() { + return path != null && path.contains("content://"); + } + + public Uri getUri() { + if (uriPath != null && uriPath.length() > 0) { + return Uri.parse(uriPath); + } + + if (isUriPath()) { + return Uri.parse(path); + } + + return PBitmapUtils.getContentUri(mimeType, id); + } + + + public float getWidthHeightRatio() { + if (height == 0) { + return 1; + } + return width * 1.00f / (height * 1.00f); + } + + /** + * 获取图片宽高类型,误差0.1 + * + * @return 1:宽图 -1:高图 0:方图 + */ + public int getWidthHeightType() { + if (getWidthHeightRatio() > 1.02f) { + return 1; + } + + if (getWidthHeightRatio() < 0.98f) { + return -1; + } + + return 0; + } + + @Override + public boolean equals(Object o) { + if (path == null) { + return false; + } + try { + ImageItem other = (ImageItem) o; + if (other.path == null) { + return false; + } + return this.path.equalsIgnoreCase(other.path); + } catch (ClassCastException e) { + e.printStackTrace(); + } + return super.equals(o); + } + + public void setUriPath(String uriPath) { + this.uriPath = uriPath; + } + + public ImageItem copy() { + ImageItem newItem = new ImageItem(); + newItem.path = this.path; + newItem.isVideo = this.isVideo; + newItem.duration = this.duration; + newItem.height = this.height; + newItem.width = this.width; + newItem.cropMode = this.cropMode; + newItem.cropUrl = this.cropUrl; + newItem.durationFormat = this.durationFormat; + newItem.id = this.id; + newItem.isPress = false; + newItem.isSelect = false; + newItem.cropRestoreInfo = cropRestoreInfo; + newItem.isOriginalImage = isOriginalImage; + return newItem; + } + + public boolean isOver2KImage() { + return width > 3000 || height > 3000; + } + + public boolean isEmpty() { + return (path == null || path.length() == 0) + && (uriPath == null || uriPath.length() == 0); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java new file mode 100644 index 0000000..7f88b83 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java @@ -0,0 +1,84 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + + + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * Description: 文件夹信息 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class ImageSet implements Serializable { + public static final String ID_ALL_MEDIA = "-1"; + public static final String ID_ALL_VIDEO = "-2"; + public String id; + public String name; + public String coverPath; + public int count; + public ImageItem cover; + public ArrayList imageItems; + public boolean isSelected = false; + + @Override + public boolean equals(Object o) { + ImageSet other = (ImageSet) o; + if (this == o) { + return true; + } + if (this.id != null && other != null && other.id != null) { + return this.id.equals(other.id); + } + return super.equals(o); + } + + public ImageSet copy() { + ImageSet imageSet = new ImageSet(); + imageSet.name = this.name; + imageSet.coverPath = this.coverPath; + imageSet.cover = this.cover; + imageSet.isSelected = this.isSelected; + imageSet.imageItems = new ArrayList<>(); + if (this.imageItems != null) { + imageSet.imageItems.addAll(this.imageItems); + } + return imageSet; + } + + public ImageSet copy(boolean isFilterVideo) { + ImageSet imageSet = new ImageSet(); + imageSet.name = this.name; + imageSet.coverPath = this.coverPath; + imageSet.cover = this.cover; + imageSet.isSelected = this.isSelected; + imageSet.imageItems = new ArrayList<>(); + if (imageItems != null && imageItems.size() > 0) { + for (ImageItem item : this.imageItems) { + if (isFilterVideo && item.isVideo()) { + continue; + } + ImageItem newItem = item.copy(); + imageSet.imageItems.add(newItem); + } + } + return imageSet; + } + + public static ImageSet allImageSet(String name) { + ImageSet imageSet = new ImageSet(); + imageSet.id = ImageSet.ID_ALL_MEDIA; + imageSet.name = name; + return imageSet; + } + + public boolean isAllMedia() { + return id == null || id.equals(ID_ALL_MEDIA); + } + + public boolean isAllVideo() { + return id != null && id.equals(ID_ALL_VIDEO); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java new file mode 100644 index 0000000..d868b9e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java @@ -0,0 +1,144 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + + +import androidx.collection.ArraySet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Set; + +/** + * MIME Type enumeration to restrict selectable media on the selection activity. Matisse only supports images and + * videos. + *

+ * Good example of mime types Android supports: + * https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/MediaFile.java + */ +public enum MimeType { + + // ============== images ============== + JPEG("image/jpeg", arraySetOf( + "jpg", + "jpeg" + )), + PNG("image/png", arraySetOf( + "png" + )), + GIF("image/gif", arraySetOf( + "gif" + )), + BMP("image/x-ms-bmp", arraySetOf( + "bmp", "x-ms-bmp" + )), + WEBP("image/webp", arraySetOf( + "webp" + )), + + // ============== videos ============== + MPEG("video/mpeg", arraySetOf( + "mpeg", + "mpg" + )), + MP4("video/mp4", arraySetOf( + "mp4" + )), + QUICKTIME("video/quicktime", arraySetOf( + "mov", "quicktime" + )), + THREEGPP("video/3gpp", arraySetOf( + "3gp", + "3gpp" + )), + THREEGPP2("video/3gpp2", arraySetOf( + "3g2", + "3gpp2" + )), + MKV("video/x-matroska", arraySetOf( + "mkv", "x-matroska" + )), + WEBM("video/webm", arraySetOf( + "webm" + )), + TS("video/mp2ts", arraySetOf( + "ts", "mp2ts" + )), + AVI("video/avi", arraySetOf( + "avi" + )); + + private final String mMimeTypeName; + private final Set mExtensions; + + MimeType(String mimeTypeName, Set extensions) { + mMimeTypeName = mimeTypeName; + mExtensions = extensions; + } + + public Set getExtensions() { + return mExtensions; + } + + public String getSuffix() { + return new ArrayList<>(mExtensions).get(0); + } + + public static Set ofAll() { + return EnumSet.allOf(MimeType.class); + } + + public static Set of(MimeType type, MimeType... rest) { + return EnumSet.of(type, rest); + } + + public static Set ofImage() { + return EnumSet.of(JPEG, PNG, GIF, BMP, WEBP); + } + + public static Set ofVideo() { + return EnumSet.of(MPEG, MP4, QUICKTIME, THREEGPP, THREEGPP2, MKV, WEBM, TS, AVI); + } + + public static boolean isImage(String mimeType) { + if (mimeType == null) return false; + return mimeType.startsWith("image"); + } + + public static boolean isVideo(String mimeType) { + if (mimeType == null) return false; + return mimeType.startsWith("video"); + } + + public static boolean isGif(String mimeType) { + if (mimeType == null) return false; + return mimeType.equals(MimeType.GIF.toString()); + } + + private static Set arraySetOf(String... suffixes) { + return new ArraySet<>(Arrays.asList(suffixes)); + } + + @Override + public String toString() { + return mMimeTypeName; + } + + public static ArrayList getMimeTypeList(Set mimeTypes) { + if (mimeTypes == null) { + return new ArrayList<>(); + } + ArrayList mimeList = new ArrayList<>(); + for (MimeType mimeType : mimeTypes) { + if (mimeType.mExtensions != null) { + for (String s : mimeType.mExtensions) { + if (MimeType.isImage(String.valueOf(mimeType))) { + mimeList.add("image/" + s); + } else if (MimeType.isVideo(String.valueOf(mimeType))) { + mimeList.add("video/" + s); + } + } + } + } + return mimeList; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java new file mode 100644 index 0000000..66140c3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java @@ -0,0 +1,59 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +/** + * Time: 2019/10/18 9:18 + * Author:ypx + * Description: 选择器调用失败的回调 + */ +public enum PickerError { + + CANCEL(-26883, ""), + MEDIA_NOT_FOUND(-26884, "not found media files"), + PRESENTER_NOT_FOUND(-26885, "not found presenter,you must be implements IMultiPickerBindPresenter or ICropPickerBindPresenter"), + UI_CONFIG_NOT_FOUND(-26886, "presenter not found uiConfig,please check IMultiPickerBindPresenter or ICropPickerBindPresenter's getUiConfig() method realize"), + SELECT_CONFIG_NOT_FOUND(-26887, "not found selectConfig or cropConfig"), + CROP_URL_NOT_FOUND(-26888, "not found imagePath to crop"), + CROP_EXCEPTION(-26889, "crop exception"), + TAKE_PHOTO_FAILED(-268890, "takePhoto failed"), + OTHER(-26891, "other error"), + MIMETYPES_EMPTY(-268892, "mimeTypes size is 0"), + IMG_SIZE_ERROR(-268893, "The image cannot exceed 10MB"); + + + private int mCode = 0; + private String mMessage = ""; + + PickerError(int code, String msg) { + mCode = code; + mMessage = msg; + } + + public void setMessage(String mMessage) { + this.mMessage = mMessage; + } + + public static PickerError valueOf(int code) { + if (code == CANCEL.getCode()) { + return CANCEL; + } else if (code == PRESENTER_NOT_FOUND.getCode()) { + return PRESENTER_NOT_FOUND; + } else if (code == UI_CONFIG_NOT_FOUND.getCode()) { + return UI_CONFIG_NOT_FOUND; + } else if (code == SELECT_CONFIG_NOT_FOUND.getCode()) { + return SELECT_CONFIG_NOT_FOUND; + } else if (code == MEDIA_NOT_FOUND.getCode()) { + return MEDIA_NOT_FOUND; + } else if (code == IMG_SIZE_ERROR.getCode()) { + return IMG_SIZE_ERROR; + } + return OTHER; + } + + public int getCode() { + return mCode; + } + + public String getMessage() { + return mMessage; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java new file mode 100644 index 0000000..a736f61 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java @@ -0,0 +1,139 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +import android.content.Context; + + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + +import java.util.ArrayList; + +/** + * Time: 2019/10/18 9:18 + * Author:ypx + * Description: 选择器Item不可选中的原因码 + */ +public class PickerItemDisableCode { + public static final int NORMAL = 0; + public static final int DISABLE_IN_SHIELD = 1; + public static final int DISABLE_OVER_MAX_COUNT = 2; + public static final int DISABLE_ONLY_SELECT_IMAGE = 3; + public static final int DISABLE_ONLY_SELECT_VIDEO = 4; + public static final int DISABLE_VIDEO_OVER_MAX_DURATION = 5; + public static final int DISABLE_VIDEO_LESS_MIN_DURATION = 6; + public static final int DISABLE_VIDEO_ONLY_SINGLE_PICK = 7; + + public static String getMessageFormCode(Context context, int code, IPickerPresenter presenter, BaseSelectConfig selectConfig) { + String message = ""; + switch (code) { + case DISABLE_IN_SHIELD: + message = context.getString(R.string.picker_str_tip_shield); + break; + case DISABLE_OVER_MAX_COUNT: + presenter.overMaxCountTip(context, selectConfig.getMaxCount()); + message = ""; + break; + case DISABLE_ONLY_SELECT_IMAGE: + message = context.getString(R.string.picker_str_tip_only_select_image); + break; + case DISABLE_ONLY_SELECT_VIDEO: + message = context.getString(R.string.picker_str_tip_only_select_video); + break; + case DISABLE_VIDEO_OVER_MAX_DURATION: + message = context.getString(R.string.picker_str_str_video_over_max_duration) + + selectConfig.getMaxVideoDurationFormat(context); + break; + case DISABLE_VIDEO_LESS_MIN_DURATION: + message = context.getString(R.string.picker_str_tip_video_less_min_duration) + + selectConfig.getMinVideoDurationFormat(context); + break; + case DISABLE_VIDEO_ONLY_SINGLE_PICK: + message = context.getString(R.string.picker_str_tip_only_select_one_video); + break; + } + return message; + } + + + public static int getItemDisableCode(ImageItem imageItem, BaseSelectConfig selectConfig, + ArrayList selectList, + boolean isContainsThisItem) { + boolean isItemEnable = true; + int disableCode = PickerItemDisableCode.NORMAL; + + //如果在屏蔽列表中,代表不可选择 + if (selectConfig.isShieldItem(imageItem)) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_IN_SHIELD; + } + + //如果是视频item + if (imageItem.isVideo()) { + //如果只能选择图片和视频类型一种,并且当前已经选择了图片,则该视频不可以选中 + if (isItemEnable + && selectConfig.isSinglePickImageOrVideoType() + && selectedFirstItemIsImage(selectList)) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_ONLY_SELECT_IMAGE; + } + //视频时长不符合选择条件 + else if (isItemEnable + && imageItem.duration > selectConfig.getMaxVideoDuration()) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_VIDEO_OVER_MAX_DURATION; + } else if (isItemEnable + && imageItem.duration < selectConfig.getMinVideoDuration()) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_VIDEO_LESS_MIN_DURATION; + } + //如果视频只能单选并且已经选过视频 + else if (isItemEnable + && selectConfig.isVideoSinglePick() + && isSelectedListContainsVideo(selectList) + && !isContainsThisItem) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_VIDEO_ONLY_SINGLE_PICK; + } + } + //如果是图片item + else { + //如果只能选择图片和视频类型一种,并且当前已经选择了视频,则该图片不可以选中 + if (selectConfig.isSinglePickImageOrVideoType() + && selectedFirstItemIsVideo(selectList)) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_ONLY_SELECT_VIDEO; + } + } + + //已经超过最大选中数量 + if (isItemEnable && hasSelectedList(selectList) && selectList.size() >= selectConfig.getMaxCount() + && !isContainsThisItem) { + disableCode = PickerItemDisableCode.DISABLE_OVER_MAX_COUNT; + } + + return disableCode; + } + + private static boolean selectedFirstItemIsVideo(ArrayList selectList) { + return hasSelectedList(selectList) && selectList.get(0) != null && selectList.get(0).isVideo(); + } + + private static boolean selectedFirstItemIsImage(ArrayList selectList) { + return hasSelectedList(selectList) && selectList.get(0) != null && !selectList.get(0).isVideo(); + } + + private static boolean hasSelectedList(ArrayList selectList) { + return selectList != null && selectList.size() > 0; + } + + + private static boolean isSelectedListContainsVideo(ArrayList selectList) { + for (ImageItem imageItem : selectList) { + if (imageItem.isVideo()) { + return true; + } + } + return false; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java new file mode 100644 index 0000000..0933d5e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java @@ -0,0 +1,13 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +/** + * Description: 图片选择模式 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public interface SelectMode { + int MODE_SINGLE = 0; + int MODE_MULTI = 1; + int MODE_CROP = 3; +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java new file mode 100644 index 0000000..9dc6f04 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java @@ -0,0 +1,62 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import java.io.Serializable; + +public class UriPathInfo implements Serializable, Parcelable { + public Uri uri; + public String absolutePath; + + public UriPathInfo(Uri uri, String absolutePath) { + this.uri = uri; + this.absolutePath = absolutePath; + } + + protected UriPathInfo(Parcel in) { + uri = in.readParcelable(Uri.class.getClassLoader()); + absolutePath = in.readString(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriPathInfo createFromParcel(Parcel in) { + return new UriPathInfo(in); + } + + @Override + public UriPathInfo[] newArray(int size) { + return new UriPathInfo[size]; + } + }; + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(uri, flags); + dest.writeString(absolutePath); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java new file mode 100644 index 0000000..78040f6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java @@ -0,0 +1,195 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + +import android.content.Context; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Set; + +/** + * Time: 2019/9/30 11:05 + * Author:ypx + * Description: 配置类基类 + */ +public class BaseSelectConfig implements Serializable { + private int maxCount; + private int minCount; + private long minVideoDuration = 0; + private long maxVideoDuration = 1200000000L; + private int columnCount = 4; + private boolean isShowCamera; + private boolean isShowCameraInAllMedia; + private boolean isVideoSinglePick = true; + private boolean isShowVideo = true; + private boolean isShowImage = true; + private boolean isLoadGif = false; + + private boolean isSinglePickAutoComplete = false; + + /** + * 图片和视频只能选择一个 + */ + private boolean isSinglePickImageOrVideoType = false; + private Set mimeTypes = MimeType.ofAll(); + private ArrayList shieldImageList = new ArrayList<>(); + + public boolean isShowCameraInAllMedia() { + return isShowCameraInAllMedia; + } + + public void setShowCameraInAllMedia(boolean showCameraInAllMedia) { + isShowCameraInAllMedia = showCameraInAllMedia; + } + + public ArrayList getShieldImageList() { + return shieldImageList; + } + + public void setShieldImageList(ArrayList shieldImageList) { + this.shieldImageList = shieldImageList; + } + + public boolean isSinglePickImageOrVideoType() { + return isSinglePickImageOrVideoType; + } + + public void setSinglePickImageOrVideoType(boolean singlePickImageOrVideoType) { + isSinglePickImageOrVideoType = singlePickImageOrVideoType; + } + + public int getMinCount() { + return minCount; + } + + public void setMinCount(int minCount) { + this.minCount = minCount; + } + + public long getMinVideoDuration() { + return minVideoDuration; + } + + public void setMinVideoDuration(long minVideoDuration) { + this.minVideoDuration = minVideoDuration; + } + + public long getMaxVideoDuration() { + return maxVideoDuration; + } + + public String getMaxVideoDurationFormat(Context context) { + return PDateUtil.formatTime(context, maxVideoDuration); + } + + public String getMinVideoDurationFormat(Context context) { + return PDateUtil.formatTime(context, minVideoDuration); + } + + public void setMaxVideoDuration(long maxVideoDuration) { + this.maxVideoDuration = maxVideoDuration; + } + + public int getColumnCount() { + return columnCount; + } + + public void setColumnCount(int columnCount) { + this.columnCount = columnCount; + } + + public int getMaxCount() { + return maxCount; + } + + public void setMaxCount(int maxCount) { + this.maxCount = maxCount; + } + + public boolean isShowCamera() { + return isShowCamera; + } + + public void setShowCamera(boolean showCamera) { + isShowCamera = showCamera; + } + + public boolean isVideoSinglePick() { + return isVideoSinglePick; + } + + public void setVideoSinglePick(boolean videoSinglePick) { + isVideoSinglePick = videoSinglePick; + } + + public boolean isShowVideo() { + return isShowVideo; + } + + public void setShowVideo(boolean showVideo) { + isShowVideo = showVideo; + } + + public boolean isShowImage() { + return isShowImage; + } + + public boolean isOnlyShowImage() { + return isShowImage && !isShowVideo; + } + + public boolean isOnlyShowVideo() { + return isShowVideo && !isShowImage; + } + + public void setShowImage(boolean showImage) { + isShowImage = showImage; + } + + public boolean isLoadGif() { + return isLoadGif; + } + + public void setLoadGif(boolean loadGif) { + isLoadGif = loadGif; + } + + public Set getMimeTypes() { + return mimeTypes; + } + + public void setMimeTypes(Set mimeTypes) { + this.mimeTypes = mimeTypes; + } + + public boolean isSinglePickAutoComplete() { + return isSinglePickAutoComplete; + } + + public void setSinglePickAutoComplete(boolean singlePickAutoComplete) { + isSinglePickAutoComplete = singlePickAutoComplete; + } + + public boolean isVideoSinglePickAndAutoComplete() { + return isVideoSinglePick() && isSinglePickAutoComplete(); + } + + /** + * 是否屏蔽某个URL + */ + public boolean isShieldItem(ImageItem imageItem) { + if (shieldImageList == null || shieldImageList.size() == 0) { + return false; + } + for (ImageItem item : shieldImageList) { + if (item.equals(imageItem)) { + return true; + } + } + return false; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java new file mode 100644 index 0000000..1f1b080 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java @@ -0,0 +1,166 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + +import android.graphics.Color; +import android.util.Size; + +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.Info; + + +/** + * Time: 2019/10/27 18:53 + * Author:ypx + * Description: 单图剪裁配置类 + */ +public class CropConfig extends BaseSelectConfig { + //充满式剪裁 + public static final int STYLE_FILL = 1; + //留白式剪裁 + public static final int STYLE_GAP = 2; + private int cropRatioX = 1; + private int cropRatioY = 1; + private boolean isCircle = false; + private int cropRectMargin = 0; + private int cropTaskFrame = 0; + + private int cropStyle = STYLE_FILL; + private int cropGapBackgroundColor = Color.BLACK; + + private boolean saveInDCIM = false; + + private Size outPutSize; + private long maxOutPutByte; + private boolean isLessOriginalByte; + private Info cropRestoreInfo; + private boolean isSingleCropCutNeedTop = false; + + public boolean isSingleCropCutNeedTop() { + return isSingleCropCutNeedTop; + } + + public void setSingleCropCutNeedTop(boolean singleCropCutNeedTop) { + isSingleCropCutNeedTop = singleCropCutNeedTop; + } + + public Size getOutPutSize() { + return outPutSize; + } + + public void setOutPutSize(Size outPutSize) { + this.outPutSize = outPutSize; + } + + public long getMaxOutPutByte() { + return maxOutPutByte; + } + + public void setMaxOutPutByte(long maxOutPutByte) { + this.maxOutPutByte = maxOutPutByte; + } + + public boolean isLessOriginalByte() { + return isLessOriginalByte; + } + + public void setLessOriginalByte(boolean lessOriginalByte) { + isLessOriginalByte = lessOriginalByte; + } + + public Info getCropRestoreInfo() { + return cropRestoreInfo; + } + + public void setCropRestoreInfo(Info cropRestoreInfo) { + this.cropRestoreInfo = cropRestoreInfo; + } + + public boolean isSaveInDCIM() { + return saveInDCIM; + } + + public void saveInDCIM(boolean saveInDCIM) { + this.saveInDCIM = saveInDCIM; + } + + public int getCropStyle() { + return cropStyle; + } + + public void setCropStyle(int cropStyle) { + this.cropStyle = cropStyle; + } + + public int getCropGapBackgroundColor() { + return cropGapBackgroundColor; + } + + public void setCropGapBackgroundColor(int cropGapBackgroundColor) { + this.cropGapBackgroundColor = cropGapBackgroundColor; + } + + public boolean isCircle() { + return isCircle; + } + + public void setCircle(boolean circle) { + isCircle = circle; + } + + + public int getCropRectMargin() { + return cropRectMargin; + } + + public void setCropRectMargin(int cropRectMargin) { + this.cropRectMargin = cropRectMargin; + } + + public int getCropTaskFrame() { + return cropTaskFrame; + } + + public void setCropTaskFrame(int cropTaskFrame) { + this.cropTaskFrame = cropTaskFrame; + } + + public int getCropRatioX() { + if (isCircle) { + return 1; + } + return cropRatioX; + } + + public void setCropRatio(int x, int y) { + this.cropRatioX = x; + this.cropRatioY = y; + } + + public int getCropRatioY() { + if (isCircle) { + return 1; + } + return cropRatioY; + } + + public boolean isGap() { + return cropStyle == STYLE_GAP; + } + + public boolean isNeedPng() { + return isCircle || getCropGapBackgroundColor() == Color.TRANSPARENT; + } + + public CropConfigParcelable getCropInfo() { + CropConfigParcelable parcelable = new CropConfigParcelable(); + parcelable.setCircle(isCircle); + parcelable.setCropGapBackgroundColor(getCropGapBackgroundColor()); + parcelable.setCropRatio(getCropRatioX(), getCropRatioY()); + parcelable.setCropRectMargin(getCropRectMargin()); + parcelable.setCropTaskFrame(getCropTaskFrame()); + parcelable.setCropRestoreInfo(getCropRestoreInfo()); + parcelable.setCropStyle(getCropStyle()); + parcelable.setLessOriginalByte(isLessOriginalByte()); + parcelable.setMaxOutPutByte(getMaxOutPutByte()); + parcelable.saveInDCIM(isSaveInDCIM()); + return parcelable; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java new file mode 100644 index 0000000..6811eed --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java @@ -0,0 +1,212 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + +import android.graphics.Color; +import android.os.Parcel; +import android.os.Parcelable; + +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.Info; + +/** + * Time: 2019/10/27 18:53 + * Author:ypx + * Description: 单图剪裁配置类 + */ +public class CropConfigParcelable implements Parcelable { + //充满式剪裁 + public static final int STYLE_FILL = 1; + //留白式剪裁 + public static final int STYLE_GAP = 2; + private int cropRatioX = 1; + private int cropRatioY = 1; + private boolean isCircle = false; + private int cropRectMargin = 0; + private int cropTaskFrame = 0; + private int cropStyle = STYLE_FILL; + private int cropGapBackgroundColor = Color.BLACK; + + private boolean saveInDCIM = false; + + // private Size outPutSize; + private long maxOutPutByte; + private boolean isLessOriginalByte; + private Info cropRestoreInfo; + private boolean isSingleCropCutNeedTop = false; + + public boolean isSingleCropCutNeedTop() { + return isSingleCropCutNeedTop; + } + + public void setSingleCropCutNeedTop(boolean singleCropCutNeedTop) { + isSingleCropCutNeedTop = singleCropCutNeedTop; + } + + protected CropConfigParcelable() { + + } + + protected CropConfigParcelable(Parcel in) { + cropRatioX = in.readInt(); + cropRatioY = in.readInt(); + isCircle = in.readByte() != 0; + cropRectMargin = in.readInt(); + cropTaskFrame = in.readInt(); + cropStyle = in.readInt(); + cropGapBackgroundColor = in.readInt(); + saveInDCIM = in.readByte() != 0; + maxOutPutByte = in.readLong(); + isLessOriginalByte = in.readByte() != 0; + cropRestoreInfo = in.readParcelable(Info.class.getClassLoader()); + isSingleCropCutNeedTop=in.readByte() != 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public CropConfigParcelable createFromParcel(Parcel in) { + return new CropConfigParcelable(in); + } + + @Override + public CropConfigParcelable[] newArray(int size) { + return new CropConfigParcelable[size]; + } + }; + + + public long getMaxOutPutByte() { + return maxOutPutByte; + } + + public void setMaxOutPutByte(long maxOutPutByte) { + this.maxOutPutByte = maxOutPutByte; + } + + public boolean isLessOriginalByte() { + return isLessOriginalByte; + } + + public void setLessOriginalByte(boolean lessOriginalByte) { + isLessOriginalByte = lessOriginalByte; + } + + public Info getCropRestoreInfo() { + return cropRestoreInfo; + } + + public void setCropRestoreInfo(Info cropRestoreInfo) { + this.cropRestoreInfo = cropRestoreInfo; + } + + public boolean isSaveInDCIM() { + return saveInDCIM; + } + + public void saveInDCIM(boolean saveInDCIM) { + this.saveInDCIM = saveInDCIM; + } + + public int getCropStyle() { + return cropStyle; + } + + public void setCropStyle(int cropStyle) { + this.cropStyle = cropStyle; + } + + public int getCropGapBackgroundColor() { + return cropGapBackgroundColor; + } + + public void setCropGapBackgroundColor(int cropGapBackgroundColor) { + this.cropGapBackgroundColor = cropGapBackgroundColor; + } + + public boolean isCircle() { + return isCircle; + } + + public void setCircle(boolean circle) { + isCircle = circle; + } + + + public int getCropRectMargin() { + return cropRectMargin; + } + + public void setCropRectMargin(int cropRectMargin) { + this.cropRectMargin = cropRectMargin; + } + + public int getCropTaskFrame() { + return cropTaskFrame; + } + + public void setCropTaskFrame(int cropTaskFrame) { + this.cropTaskFrame = cropTaskFrame; + } + + public int getCropRatioX() { + if (isCircle) { + return 1; + } + return cropRatioX; + } + + public void setCropRatio(int x, int y) { + this.cropRatioX = x; + this.cropRatioY = y; + } + + public int getCropRatioY() { + if (isCircle) { + return 1; + } + return cropRatioY; + } + + public boolean isGap() { + return cropStyle == STYLE_GAP; + } + + public boolean isNeedPng() { + return isCircle || getCropGapBackgroundColor() == Color.TRANSPARENT; + } + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(cropRatioX); + dest.writeInt(cropRatioY); + dest.writeByte((byte) (isCircle ? 1 : 0)); + dest.writeInt(cropRectMargin); + dest.writeInt(cropTaskFrame); + dest.writeInt(cropStyle); + dest.writeInt(cropGapBackgroundColor); + dest.writeByte((byte) (saveInDCIM ? 1 : 0)); + dest.writeLong(maxOutPutByte); + dest.writeByte((byte) (isLessOriginalByte ? 1 : 0)); + dest.writeParcelable(cropRestoreInfo, flags); + dest.writeByte((byte) (isSingleCropCutNeedTop ? 1 : 0)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java new file mode 100644 index 0000000..b6eb8a0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java @@ -0,0 +1,39 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +/** + * Time: 2019/9/3 13:46 + * Author:ypx + * Description:小红书剪裁配置类 + */ +public class CropSelectConfig extends BaseSelectConfig { + private ImageItem firstImageItem; + private boolean assignGapState = false; + + public CropSelectConfig() { + setSinglePickImageOrVideoType(true); + } + + public ImageItem getFirstImageItem() { + return firstImageItem; + } + + + public void setFirstImageItem(ImageItem firstImageItem) { + this.firstImageItem = firstImageItem; + } + + public boolean hasFirstImageItem() { + return firstImageItem != null && firstImageItem.width > 0 && firstImageItem.height > 0; + } + + public boolean isAssignGapState() { + return assignGapState; + } + + public void setAssignGapState(boolean assignGapState) { + this.assignGapState = assignGapState; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java new file mode 100644 index 0000000..5295978 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java @@ -0,0 +1,91 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.SelectMode; + +import java.util.ArrayList; + +/** + * Description: 多选配置项 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class MultiSelectConfig extends CropConfig { + private boolean isShowOriginalCheckBox; + private boolean isDefaultOriginal; + private boolean isCanEditPic; + private boolean isCanPreviewVideo = true; + private boolean isPreview = true; + + private int selectMode = SelectMode.MODE_MULTI; + private ArrayList lastImageList = new ArrayList<>(); + + public boolean isPreview() { + return isPreview; + } + + public void setPreview(boolean preview) { + isPreview = preview; + } + + + public ArrayList getLastImageList() { + return lastImageList; + } + + public void setLastImageList(ArrayList lastImageList) { + this.lastImageList = lastImageList; + } + + public int getSelectMode() { + return selectMode; + } + + public void setSelectMode(int selectMode) { + this.selectMode = selectMode; + } + + public boolean isShowOriginalCheckBox() { + return isShowOriginalCheckBox; + } + + public void setShowOriginalCheckBox(boolean showOriginalCheckBox) { + isShowOriginalCheckBox = showOriginalCheckBox; + } + + public boolean isDefaultOriginal() { + return isDefaultOriginal; + } + + public void setDefaultOriginal(boolean defaultOriginal) { + isDefaultOriginal = defaultOriginal; + } + + public boolean isCanEditPic() { + return isCanEditPic; + } + + public void setCanEditPic(boolean canEditPic) { + isCanEditPic = canEditPic; + } + + /** + * 是否是之前选中过的 + */ + public boolean isLastItem(ImageItem imageItem) { + if (lastImageList == null || lastImageList.size() == 0) { + return false; + } + return lastImageList.contains(imageItem); + } + + public boolean isCanPreviewVideo() { + return isCanPreviewVideo; + } + + public void setCanPreviewVideo(boolean canPreviewVideo) { + isCanPreviewVideo = canPreviewVideo; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java new file mode 100644 index 0000000..9ab2905 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java @@ -0,0 +1,263 @@ +package com.remax.visualnovel.widget.imagepicker.builder; + +import android.app.Activity; +import android.os.Bundle; + + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropActivity; +import com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropFragment; +import com.remax.visualnovel.widget.imagepicker.bean.ImageCropMode; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + + +/** + * Description: 小红书剪裁选择器构造类 + *

+ * Author: peixing.yang + * Date: 2019/2/28 + */ +public class CropPickerBuilder { + private CropSelectConfig selectConfig; + private IPickerPresenter presenter; + + public CropPickerBuilder(IPickerPresenter presenter) { + this.presenter = presenter; + this.selectConfig = new CropSelectConfig(); + } + + /** + * @param columnCount 设置列数 + */ + public CropPickerBuilder setColumnCount(int columnCount) { + selectConfig.setColumnCount(columnCount); + return this; + } + + /** + * @param duration 设置视频可选择的最大时长 + */ + public CropPickerBuilder setMaxVideoDuration(long duration) { + this.selectConfig.setMaxVideoDuration(duration); + return this; + } + + /** + * @param duration 设置视频可选择的最小时长 + */ + public CropPickerBuilder setMinVideoDuration(long duration) { + this.selectConfig.setMinVideoDuration(duration); + return this; + } + + /** + * @param maxCount 选中数量限制 + */ + public CropPickerBuilder setMaxCount(int maxCount) { + selectConfig.setMaxCount(maxCount); + return this; + } + + /** + * @param isSinglePick 是否单选视频,如果设置为true,则点击item会走presenter的clickVideo方法, + * 设置为false,则触发视频多选和预览模式 + */ + public CropPickerBuilder setVideoSinglePick(boolean isSinglePick) { + selectConfig.setVideoSinglePick(isSinglePick); + return this; + } + + /** + * @param isShowCamera 是否显示拍照item + */ + public CropPickerBuilder showCamera(boolean isShowCamera) { + selectConfig.setShowCamera(isShowCamera); + return this; + } + + /** + * 设置需要加载的文件类型 + * + * @param mimeTypes 需要加载的文件类型集合 + */ + public CropPickerBuilder mimeTypes(Set mimeTypes) { + if (mimeTypes == null || mimeTypes.size() == 0) { + return this; + } + selectConfig.setMimeTypes(mimeTypes); + return this; + } + + /** + * 设置文件加载类型 + * + * @param mimeTypes 文件类型数组 + */ + public CropPickerBuilder mimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return mimeTypes(mimeTypeSet); + } + + /** + * 设置需要过滤掉的文件类型 + * + * @param mimeTypes 需要过滤的文件类型数组 + */ + public CropPickerBuilder filterMimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return filterMimeTypes(mimeTypeSet); + } + + /** + * 设置需要过滤掉的文件类型 + * + * @param mimeTypes 文件类型集合 + */ + public CropPickerBuilder filterMimeTypes(Set mimeTypes) { + selectConfig.getMimeTypes().removeAll(mimeTypes); + return this; + } + + /** + * @param isAutoComplete 设置单选模式下是否点击item就自动回调 + */ + public CropPickerBuilder setSinglePickWithAutoComplete(boolean isAutoComplete) { + selectConfig.setSinglePickAutoComplete(isAutoComplete); + return this; + } + + //--------------- 以下是小红书剪裁特有属性 ------------------------------------- + + /** + * 在没有指定setFirstImageItem时,使用这个方法传入当前的第一张图片的宽高信息, + * 会生成一个新的FirstImageItem,其剪裁模式根据图片宽高决定,如果已经指定了FirstImageItem,则该方法无效 + * + * @param width 第一张图片的宽 + * @param height 第一张图片的高 + */ + public CropPickerBuilder setFirstImageItemSize(int width, int height) { + if (width == 0 || height == 0 || selectConfig.hasFirstImageItem()) { + return this; + } + ImageItem firstImageItem = new ImageItem(); + firstImageItem.setVideo(false); + firstImageItem.width = width; + firstImageItem.height = height; + if (Math.abs(width - height) < 5) { + firstImageItem.setCropMode(ImageCropMode.CropViewScale_FULL); + } else { + firstImageItem.setCropMode(ImageCropMode.CropViewScale_FIT); + } + return setFirstImageItem(firstImageItem); + } + + /** + * 强制指定留白模式,即一打开只有留白模式 + * + * @param isAssignGap 指定留白 + */ + public CropPickerBuilder assignGapState(boolean isAssignGap) { + selectConfig.setAssignGapState(isAssignGap); + if (isAssignGap) { + setFirstImageItemSize(1, 1); + } + return this; + } + + /** + * @param firstImageItem 设置之前选择的第一个item,用于指定默认剪裁模式,如果当前item是图片, + * 则强制所有图片剪裁模式为当前图片比例,如果当前item是视频, + * 则强制只能选择视频 + */ + public CropPickerBuilder setFirstImageItem(ImageItem firstImageItem) { + if (firstImageItem != null) { + if (firstImageItem.isVideo() || selectConfig.hasFirstImageItem()) { + return this; + } + if ((firstImageItem.width > 0 && firstImageItem.height > 0)) { + selectConfig.setFirstImageItem(firstImageItem); + } + } + return this; + } + //--------------- 以上是小红书剪裁特有属性 ------------------------------------- + + + /** + * @param selectConfig 选择配置项 + */ + public CropPickerBuilder withSelectConfig(CropSelectConfig selectConfig) { + this.selectConfig = selectConfig; + return this; + } + + + /** + * 页面直接调用剪裁器 + * + * @param activity 调用者 + * @param listener 图片视频选择回调 + */ + public void pick(Activity activity, final OnImagePickCompleteListener listener) { + checkVideoAndImage(); + if (selectConfig.getMimeTypes() == null || selectConfig.getMimeTypes().size() == 0) { + PickerErrorExecutor.executeError(listener, PickerError.MIMETYPES_EMPTY.getCode()); + presenter.tip(activity, activity.getString(R.string.picker_str_tip_mimeTypes_empty)); + return; + } + MultiImageCropActivity.intent(activity, presenter, selectConfig, listener); + } + + + /** + * fragment构建 + * + * @param imageListener 图片视频选择回调 + */ + public MultiImageCropFragment pickWithFragment(OnImagePickCompleteListener imageListener) { + checkVideoAndImage(); + MultiImageCropFragment mFragment = new MultiImageCropFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(MultiImageCropActivity.INTENT_KEY_DATA_PRESENTER, presenter); + bundle.putSerializable(MultiImageCropActivity.INTENT_KEY_SELECT_CONFIG, selectConfig); + mFragment.setArguments(bundle); + mFragment.setOnImagePickCompleteListener(imageListener); + return mFragment; + } + + /** + * 检测文件加载类型中是否全是图片或视频 + */ + private void checkVideoAndImage() { + selectConfig.setSinglePickImageOrVideoType(true); + if (selectConfig == null) { + return; + } + selectConfig.setShowVideo(false); + selectConfig.setShowImage(false); + for (MimeType mimeType : selectConfig.getMimeTypes()) { + if (MimeType.ofVideo().contains(mimeType)) { + selectConfig.setShowVideo(true); + } + if (MimeType.ofImage().contains(mimeType)) { + selectConfig.setShowImage(true); + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java new file mode 100644 index 0000000..5544da8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java @@ -0,0 +1,426 @@ +package com.remax.visualnovel.widget.imagepicker.builder; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.app.Activity; +import android.os.Bundle; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity; +import com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerFragment; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.SelectMode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + + +/** + * Description: 多选选择器构造类 + *

+ * Author: peixing.yang + * Date: 2018/9/19 16:56 + */ +public class MultiPickerBuilder { + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + + public MultiPickerBuilder(IPickerPresenter presenter) { + this.presenter = presenter; + this.selectConfig = new MultiSelectConfig(); + } + + /** + * @param isAutoComplete 设置单选模式下是否点击item就自动回调 + */ + public MultiPickerBuilder setSinglePickWithAutoComplete(boolean isAutoComplete) { + selectConfig.setSinglePickAutoComplete(isAutoComplete); + return this; + } + + /** + * @param selectLimit 设置最大数量限制 + */ + public MultiPickerBuilder setMaxCount(int selectLimit) { + selectConfig.setMaxCount(selectLimit); + return this; + } + + /** + * @param selectMode 设置选择模式 + * {@link SelectMode} + */ + public MultiPickerBuilder setSelectMode(int selectMode) { + selectConfig.setSelectMode(selectMode); + return this; + } + + /** + * @param duration 设置视频可选择的最大时长 + */ + public MultiPickerBuilder setMaxVideoDuration(long duration) { + this.selectConfig.setMaxVideoDuration(duration); + return this; + } + + /** + * @param duration 设置视频可选择的最小时长 + */ + public MultiPickerBuilder setMinVideoDuration(long duration) { + this.selectConfig.setMinVideoDuration(duration); + return this; + } + + /** + * 设置文件加载类型 + * + * @param mimeTypes 文件类型数组 + */ + public MultiPickerBuilder mimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return mimeTypes(mimeTypeSet); + } + + /** + * 设置文件加载类型 + * + * @param mimeTypes 文件类型集合 + */ + public MultiPickerBuilder filterMimeTypes(Set mimeTypes) { + if (mimeTypes != null && selectConfig != null && selectConfig.getMimeTypes() != null) { + selectConfig.getMimeTypes().removeAll(mimeTypes); + } + return this; + } + + /** + * 设置需要过滤掉的文件加载类型 + * + * @param mimeTypes 需要过滤的文件类型数组 + */ + public MultiPickerBuilder filterMimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return filterMimeTypes(mimeTypeSet); + } + + /** + * 设置需要加载的文件类型 + * + * @param mimeTypes 需要过滤的文件类型集合 + */ + public MultiPickerBuilder mimeTypes(Set mimeTypes) { + if (mimeTypes == null || mimeTypes.size() == 0) { + return this; + } + selectConfig.setMimeTypes(mimeTypes); + return this; + } + + /** + * @param columnCount 设置列数 + */ + public MultiPickerBuilder setColumnCount(int columnCount) { + selectConfig.setColumnCount(columnCount); + return this; + } + + /** + * @param showCamera 显示拍照item + */ + public MultiPickerBuilder showCamera(boolean showCamera) { + selectConfig.setShowCamera(showCamera); + return this; + } + + /** + * 只在全部媒体相册里展示拍照 + */ + public MultiPickerBuilder showCameraOnlyInAllMediaSet(boolean showCamera) { + selectConfig.setShowCameraInAllMedia(showCamera); + return this; + } + + /** + * @param isSinglePickImageOrVideoType 是否只能选择视频或图片 + */ + public MultiPickerBuilder setSinglePickImageOrVideoType(boolean isSinglePickImageOrVideoType) { + selectConfig.setSinglePickImageOrVideoType(isSinglePickImageOrVideoType); + return this; + } + + + /** + * @param isVideoSinglePick 视频是否单选 + */ + public MultiPickerBuilder setVideoSinglePick(boolean isVideoSinglePick) { + selectConfig.setVideoSinglePick(isVideoSinglePick); + return this; + } + + + //—————————————————————— 以下为微信选择器特有的属性 —————————————————————— + + /** + * @param isPreview 视频是否支持预览 + */ + public MultiPickerBuilder setPreviewVideo(boolean isPreview) { + selectConfig.setCanPreviewVideo(isPreview); + return this; + } + + /** + * @param isPreview 是否开启预览 + */ + public MultiPickerBuilder setPreview(boolean isPreview) { + selectConfig.setPreview(isPreview); + return this; + } + + /** + * @param isOriginal 设置是否支持原图选项 + */ + public MultiPickerBuilder setOriginal(boolean isOriginal) { + selectConfig.setShowOriginalCheckBox(isOriginal); + return this; + } + + /** + * @param isOriginal 设置原图选项默认值,true则代表默认打开原图,false代表不打开 + */ + public MultiPickerBuilder setDefaultOriginal(boolean isOriginal) { + selectConfig.setDefaultOriginal(isOriginal); + return this; + } + + /** + * @param imageList 设置屏蔽项,默认打开选择器不可选择屏蔽列表的媒体文件 + * @param String or ImageItem + */ + public MultiPickerBuilder setShieldList(ArrayList imageList) { + if (imageList == null || imageList.size() == 0) { + return this; + } + selectConfig.setShieldImageList(transitArray(imageList)); + return this; + } + + /** + * @param imageList 设置上一次选择的媒体文件,默认还原上一次选择,可取消 + * @param String or ImageItem + */ + public MultiPickerBuilder setLastImageList(ArrayList imageList) { + if (imageList == null || imageList.size() == 0) { + return this; + } + selectConfig.setLastImageList(transitArray(imageList)); + return this; + } + + + //—————————————————————— 以下为单图剪裁的属性 —————————————————————— + + /** + * 设置剪裁最小间距,默认充满 + * + * @param margin 间距 + */ + public MultiPickerBuilder cropRectMinMargin(int margin) { + selectConfig.setCropRectMargin(margin); + return this; + } + + /** + * 设置剪裁头像框显示的边距 + * + * @param cropTaskFrame 间距 + */ + public MultiPickerBuilder setCropTaskFrame(int cropTaskFrame) { + selectConfig.setCropTaskFrame(cropTaskFrame); + return this; + } + + /** + * 设置剪裁模式, + *

+ * MultiSelectConfig.STYLE_FILL:充满模式 + * MultiSelectConfig.STYLE_GAP:留白模式 + * + * @param style MultiSelectConfig.STYLE_FILL or MultiSelectConfig.STYLE_GAP + */ + public MultiPickerBuilder cropStyle(int style) { + selectConfig.setCropStyle(style); + return this; + } + + /** + * 设置留白剪裁模式下背景色,如果设置成透明色,则默认生成png图片 + * + * @param color 背景色 + */ + public MultiPickerBuilder cropGapBackgroundColor(int color) { + selectConfig.setCropGapBackgroundColor(color); + return this; + } + + /** + * 设置单张图片剪裁比例 + * + * @param x 剪裁比例x + * @param y 剪裁比例y + */ + public MultiPickerBuilder setCropRatio(int x, int y) { + selectConfig.setCropRatio(x, y); + return this; + } + + /** + * 开启圆形剪裁 + */ + public MultiPickerBuilder cropAsCircle() { + selectConfig.setCircle(true); + return this; + } + + /** + * 剪裁完成的图片是否保存在DCIM目录下 + * + * @param isSaveInDCIM true:存储在系统目录DCIM下 false:存储在 data/包名/files/imagePicker/ 目录下 + */ + public MultiPickerBuilder cropSaveInDCIM(boolean isSaveInDCIM) { + selectConfig.saveInDCIM(isSaveInDCIM); + return this; + } + + /** + * 单图剪裁页面,剪裁框是否在最上层 + * + * @param singleCropCutNeedTop 剪裁框是否在activity最顶层(会盖住所有的view) + */ + public MultiPickerBuilder setSingleCropCutNeedTop(boolean singleCropCutNeedTop) { + selectConfig.setSingleCropCutNeedTop(singleCropCutNeedTop); + return this; + } + + //—————————————————————— 以上为单图剪裁的属性 —————————————————————— + + /** + * @param config 选择配置 + */ + public MultiPickerBuilder withMultiSelectConfig(MultiSelectConfig config) { + this.selectConfig = config; + return this; + } + + /** + * fragment模式调用 + * + * @param completeListener 选择回调 + * @return MultiImagePickerFragment + */ + public MultiImagePickerFragment pickWithFragment(OnImagePickCompleteListener completeListener) { + checkVideoAndImage(); + MultiImagePickerFragment mFragment = new MultiImagePickerFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(INTENT_KEY_SELECT_CONFIG, selectConfig); + bundle.putSerializable(INTENT_KEY_PRESENTER, presenter); + mFragment.setArguments(bundle); + mFragment.setOnImagePickCompleteListener(completeListener); + return mFragment; + } + + /** + * 直接开启相册选择 + * + * @param context 页面调用者 + * @param listener 选择器选择回调 + */ + public void pick(Activity context, final OnImagePickCompleteListener listener) { + checkVideoAndImage(); + if (selectConfig.getMimeTypes() == null || selectConfig.getMimeTypes().size() == 0) { + PickerErrorExecutor.executeError(listener, PickerError.MIMETYPES_EMPTY.getCode()); + presenter.tip(context, context.getString(R.string.picker_str_tip_mimeTypes_empty)); + return; + } + MultiImagePickerActivity.intent(context, selectConfig, presenter, listener); + } + + /** + * 调用单图剪裁 + * + * @param context 页面调用者 + * @param listener 选择器剪裁回调,只支持一张图片 + */ + public void crop(Activity context, OnImagePickCompleteListener listener) { + setMaxCount(1); + filterMimeTypes(MimeType.ofVideo()); + setSinglePickImageOrVideoType(false); + setSinglePickWithAutoComplete(true); + setVideoSinglePick(false); + setShieldList(null); + setLastImageList(null); + setPreview(false); + selectConfig.setSelectMode(SelectMode.MODE_CROP); + if (selectConfig.isCircle()) { + selectConfig.setCropRatio(1, 1); + } + if (selectConfig.getMimeTypes() == null || selectConfig.getMimeTypes().size() == 0) { + PickerErrorExecutor.executeError(listener, PickerError.MIMETYPES_EMPTY.getCode()); + presenter.tip(context, context.getString(R.string.picker_str_tip_mimeTypes_empty)); + return; + } + MultiImagePickerActivity.intent(context, selectConfig, presenter, listener); + } + + /** + * 检测文件加载类型中是否全是图片或视频 + */ + private void checkVideoAndImage() { + if (selectConfig == null) { + return; + } + selectConfig.setShowVideo(false); + selectConfig.setShowImage(false); + for (MimeType mimeType : selectConfig.getMimeTypes()) { + if (MimeType.ofVideo().contains(mimeType)) { + selectConfig.setShowVideo(true); + } + if (MimeType.ofImage().contains(mimeType)) { + selectConfig.setShowImage(true); + } + } + } + + /** + * 数据类型转化 + */ + private ArrayList transitArray(ArrayList imageList) { + ArrayList items = new ArrayList<>(); + for (T t : imageList) { + if (t instanceof String) { + ImageItem imageItem = new ImageItem(); + imageItem.path = (String) t; + items.add(imageItem); + } else if (t instanceof ImageItem) { + items.add((ImageItem) t); + } else { + throw new RuntimeException("ImageList item must be instanceof String or ImageItem"); + } + } + return items; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java new file mode 100644 index 0000000..b7146d8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java @@ -0,0 +1,15 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import androidx.annotation.Nullable; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + + +public interface ICameraExecutor { + + void takePhoto(); + + void takeVideo(); + + void onTakePhotoResult(@Nullable ImageItem imageItem); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java new file mode 100644 index 0000000..435a409 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java @@ -0,0 +1,16 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +import java.util.List; + +public interface IReloadExecutor { + + /** + * 根据当前选择列表,重新刷新选择器选择状态 + * + * @param selectedList 当前选中列表 + */ + void reloadPickerWithList(List selectedList); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java new file mode 100644 index 0000000..4a00b58 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java @@ -0,0 +1,301 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATA; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATE_MODIFIED; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DURATION; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.HEIGHT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MIME_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.WIDTH; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants._ID; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Set; + + +/** + * Description: 媒体数据 + *

+ * Author: peixing.yang + * Date: 2019/4/11 + */ +public class MediaItemsDataSource implements LoaderManager.LoaderCallbacks { + private static final int LOADER_ID = 2; + private WeakReference mContext; + private LoaderManager mLoaderManager; + private MediaItemProvider mediaItemProvider; + private int preloadSize = 40; + private Set mimeTypeSet = MimeType.ofAll(); + + public MediaItemsDataSource setMimeTypeSet(BaseSelectConfig config) { + mimeTypeSet = config.getMimeTypes(); + return this; + } + + public MediaItemsDataSource setMimeTypeSet(Set mimeTypeSet) { + this.mimeTypeSet = mimeTypeSet; + return this; + } + + public MediaItemsDataSource preloadSize(int preloadSize) { + this.preloadSize = preloadSize; + return this; + } + + public void loadMediaItems(MediaItemProvider mediaItemProvider) { + this.mediaItemProvider = mediaItemProvider; + mLoaderManager.initLoader(LOADER_ID, null, this); + } + + public static MediaItemsDataSource create(FragmentActivity activity, ImageSet set) { + return new MediaItemsDataSource(activity, set); + } + + private ImageSet set; + + private MediaItemsDataSource(FragmentActivity activity, ImageSet set) { + this.set = set; + mContext = new WeakReference<>(activity); + mLoaderManager = LoaderManager.getInstance(mContext.get()); + } + + @NonNull + @Override + public Loader onCreateLoader(int id, Bundle args) { + Context context = mContext.get(); + if (context == null) { + return null; + } + return MediaItemsLoader.newInstance(context, set, mimeTypeSet); + } + + private Cursor cursor; + private Thread thread; + + @Override + public void onLoadFinished(@NonNull Loader loader, final Cursor cursor) { + final FragmentActivity context = mContext.get(); + if (context == null | cursor == null || cursor.isClosed()) { + return; + } + this.cursor = cursor; + if (thread != null && thread.isAlive()) { + return; + } + thread = new Thread(runnable); + thread.start(); + } + + + private Runnable runnable = new Runnable() { + @Override + public void run() { + final FragmentActivity context = mContext.get(); + final ArrayList imageItems = new ArrayList<>(); + ArrayList allVideoItems = new ArrayList<>(); + if (!context.isDestroyed() && !cursor.isClosed() && cursor.moveToFirst()) { + try { + + do { + ImageItem item = new ImageItem(); + item.id = getLong(cursor, _ID); + item.mimeType = getString(cursor, MIME_TYPE); + item.displayName = getString(cursor, DISPLAY_NAME); + try { + item.path = getString(cursor, DATA); + } catch (Exception ignored) { + + } + + Uri urlPath = item.getUri(); + if (urlPath != null) { + item.setUriPath(urlPath.toString()); + } + + if (item.path == null || item.path.length() == 0) { + item.path = urlPath.toString(); + } + + item.width = getInt(cursor, WIDTH); + item.height = getInt(cursor, HEIGHT); + item.setVideo(MimeType.isVideo(item.mimeType)); + item.time = getLong(cursor, DATE_MODIFIED); + item.timeFormat = PDateUtil.getStrTime(context, item.time); + + + //没有查询到路径 + if (item.path == null || item.path.length() == 0) { + continue; + } + + //视频 + if (item.isVideo()) { + item.duration = getLong(cursor, DURATION); + if (item.duration == 0) { + continue; + } + item.durationFormat = PDateUtil.getVideoDuration(item.duration); + + //如果当前加载的是全部文件,需要拼凑一个全部视频的虚拟文件夹 + if (set.isAllMedia()) { + allVideoItems.add(item); + } + } + //图片 + else { + //如果媒体信息中不包含图片的宽高,则手动获取文件宽高 + /*if (item.width == 0 || item.height == 0) { + if (!item.isUriPath()) {//此方法读取不到文件了 + int[] size = PBitmapUtils.getImageWidthHeight(item.path); + item.width = size[0]; + item.height = size[1]; + } + }*/ + } + //添加到文件列表中 + imageItems.add(item); + //回调预加载数据源 + if (preloadProvider != null && imageItems.size() == preloadSize) { + notifyPreloadItem(context, imageItems); + } + } while (!context.isDestroyed() && !cursor.isClosed() && cursor.moveToNext()); + } catch (Exception e) { + + } + } + //手动生成一个虚拟的全部视频文件夹 + ImageSet allVideoSet = null; + if (allVideoItems.size() > 0) { + allVideoSet = new ImageSet(); + allVideoSet.id = ImageSet.ID_ALL_VIDEO; + allVideoSet.coverPath = allVideoItems.get(0).path; + allVideoSet.cover = allVideoItems.get(0); + allVideoSet.count = allVideoItems.size(); + allVideoSet.imageItems = allVideoItems; + allVideoSet.name = context.getString(R.string.picker_str_folder_item_video); + } + //回调所有数据 + notifyMediaItem(context, imageItems, allVideoSet); + } + }; + + /** + * 回调预加载的媒体文件,主线程 + * + * @param context FragmentActivity + * @param imageItems 预加载列表 + */ + private void notifyPreloadItem(final FragmentActivity context, final ArrayList imageItems) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + if (context.isDestroyed()) { + return; + } + preloadProvider.providerMediaItems(imageItems); + preloadProvider = null; + } + }); + } + + /** + * 回调所有数据 + * + * @param context FragmentActivity + * @param imageItems 所有文件 + * @param allVideoSet 当加载所有媒体库文件时,默认会生成一个全部视频的文件夹,是本地虚拟的文件夹 + */ + private void notifyMediaItem(final FragmentActivity context, final ArrayList imageItems, + final ImageSet allVideoSet) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + if (context.isDestroyed()) { + return; + } + if (mediaItemProvider != null) { + mediaItemProvider.providerMediaItems(imageItems, allVideoSet); + } + + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(LOADER_ID); + } + } + }); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + + } + + public interface MediaItemProvider { + void providerMediaItems(ArrayList imageItems, ImageSet allVideoSet); + } + + private MediaItemPreloadProvider preloadProvider; + + public void setPreloadProvider(MediaItemPreloadProvider preloadProvider) { + this.preloadProvider = preloadProvider; + } + + public interface MediaItemPreloadProvider { + void providerMediaItems(ArrayList imageItems); + } + + private long getLong(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getLong(index); + } else { + return 0; + } + } + + private int getInt(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getInt(index); + } else { + return 0; + } + } + + private String getString(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getString(index); + } else { + return ""; + } + } + + private int hasColumn(Cursor data, String id) { + if (data.isClosed()) { + return -1; + } + try { + return data.getColumnIndexOrThrow(id); + } catch (Exception e) { + return -1; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java new file mode 100644 index 0000000..e998242 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java @@ -0,0 +1,84 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.content.Context; +import android.net.Uri; +import android.provider.MediaStore; + +import androidx.loader.content.CursorLoader; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; + +import java.util.ArrayList; +import java.util.Set; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATA; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATE_MODIFIED; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DURATION; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.HEIGHT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_IMAGE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_VIDEO; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MIME_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.SIZE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.WIDTH; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants._ID; + + +public class MediaItemsLoader extends CursorLoader { + private static final Uri QUERY_URI = MediaStore.Files.getContentUri("external"); + private static final String[] PROJECTION = { + _ID, + DATA, + DISPLAY_NAME, + WIDTH, + HEIGHT, + MIME_TYPE, + SIZE, + DURATION, + DATE_MODIFIED}; + + private static final String ORDER_BY = MediaStore.Files.FileColumns.DATE_MODIFIED + " DESC"; + + private MediaItemsLoader(Context context, String selection, String[] selectionArgs) { + super(context, QUERY_URI, PROJECTION, selection, selectionArgs, ORDER_BY); + } + + static CursorLoader newInstance(Context context, ImageSet album, Set mimeTypeSet) { + String[] selectionsArgs; + String albumSelections = ""; + String mimeSelections = ""; + int index = 0; + ArrayList arrayList = MimeType.getMimeTypeList(mimeTypeSet); + if (album.isAllMedia() || album.isAllVideo()) { + selectionsArgs = new String[arrayList.size()]; + } else { + selectionsArgs = new String[arrayList.size() + 1]; + selectionsArgs[0] = album.id; + index = 1; + albumSelections = " bucket_id=? AND "; + } + + for (String mimeType : arrayList) { + selectionsArgs[index] = mimeType; + mimeSelections = String.format("%s =? OR %s", MediaStore.Files.FileColumns.MIME_TYPE, mimeSelections); + index++; + } + + if (mimeSelections.endsWith(" OR ")) { + mimeSelections = mimeSelections.substring(0, mimeSelections.length() - 4); + } + + String selections = albumSelections + "(" + MEDIA_TYPE + "=" + MEDIA_TYPE_IMAGE + " OR " + + MEDIA_TYPE + "=" + MEDIA_TYPE_VIDEO + ")" + + " AND " + SIZE + ">0" + " AND (" + mimeSelections + ")"; + + return new MediaItemsLoader(context, selections, selectionsArgs); + } + + @Override + public void onContentChanged() { + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java new file mode 100644 index 0000000..3d8283d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java @@ -0,0 +1,149 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Set; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_ID; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_COUNT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_URI; + + +/** + * Description: 媒体文件夹数据 + *

+ * Author: peixing.yang + * Date: 2019/4/11 + */ +public class MediaSetsDataSource implements LoaderManager.LoaderCallbacks { + private static final int LOADER_ID = 1; + private WeakReference mContext; + private LoaderManager mLoaderManager; + private MediaSetProvider mediaSetProvider; + private boolean isLoadVideo; + private boolean isLoadImage; + + private Set mimeTypeSet = MimeType.ofAll(); + + public MediaSetsDataSource setMimeTypeSet(BaseSelectConfig config) { + isLoadImage = config.isShowImage(); + isLoadVideo = config.isShowVideo(); + mimeTypeSet = config.getMimeTypes(); + return this; + } + + public MediaSetsDataSource setMimeTypeSet(Set mimeTypeSet) { + this.mimeTypeSet = mimeTypeSet; + for (MimeType mimeType : mimeTypeSet) { + if (MimeType.ofVideo().contains(mimeType)) { + isLoadVideo = true; + } + if (MimeType.ofImage().contains(mimeType)) { + isLoadImage = true; + } + } + return this; + } + + public void loadMediaSets(MediaSetProvider mediaSetProvider) { + this.mediaSetProvider = mediaSetProvider; + mLoaderManager.initLoader(LOADER_ID, null, this); + } + + public static MediaSetsDataSource create(FragmentActivity activity) { + return new MediaSetsDataSource(activity); + } + + private MediaSetsDataSource(FragmentActivity activity) { + mContext = new WeakReference<>(activity); + mLoaderManager = LoaderManager.getInstance(mContext.get()); + } + + @NonNull + @Override + public Loader onCreateLoader(int id, Bundle args) { + Context context = mContext.get(); + return MediaSetsLoader.create(context, mimeTypeSet, isLoadVideo, isLoadImage); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor cursor) { + FragmentActivity context = mContext.get(); + if (context == null) { + return; + } + ArrayList imageSetList = new ArrayList<>(); + if (!context.isDestroyed() && cursor.moveToFirst() && !cursor.isClosed()) { + do { + ImageSet imageSet = new ImageSet(); + imageSet.id = getString(cursor, COLUMN_BUCKET_ID); + imageSet.name = getString(cursor, COLUMN_BUCKET_DISPLAY_NAME); + imageSet.coverPath = getString(cursor, COLUMN_URI); + imageSet.count = getInt(cursor, COLUMN_COUNT); + imageSetList.add(imageSet); + } while (!context.isDestroyed() && cursor.moveToNext() && !cursor.isClosed()); + } + + if (mediaSetProvider != null) { + mediaSetProvider.providerMediaSets(imageSetList); + } + + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(LOADER_ID); + } + + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + + } + + public interface MediaSetProvider { + void providerMediaSets(ArrayList imageSets); + } + + private int getInt(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getInt(index); + } else { + return 0; + } + } + + private String getString(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getString(index); + } else { + return ""; + } + } + + private int hasColumn(Cursor data, String id) { + if (data.isClosed()) { + return -1; + } + try { + return data.getColumnIndexOrThrow(id); + } catch (Exception e) { + return -1; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java new file mode 100644 index 0000000..7a16b44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java @@ -0,0 +1,174 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.BUCKET_ORDER_BY; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_ID; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_COUNT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_URI; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_IMAGE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_VIDEO; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MIME_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.QUERY_URI; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.SIZE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants._ID; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.LongSparseArray; + +import androidx.loader.content.CursorLoader; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +; + + +public class MediaSetsLoader extends CursorLoader { + private boolean isLoadVideo; + private boolean isLoadImage; + private static final String[] COLUMNS = { + _ID, + COLUMN_BUCKET_ID, + COLUMN_BUCKET_DISPLAY_NAME, + COLUMN_URI, + COLUMN_COUNT}; + private static final String[] PROJECTION = { + _ID, + COLUMN_BUCKET_ID, + COLUMN_BUCKET_DISPLAY_NAME, + MIME_TYPE}; + + private MediaSetsLoader(Context context, String selection, String[] selectionArgs, boolean isLoadVideo, boolean isLoadImage) { + super(context, QUERY_URI, PROJECTION, selection, selectionArgs, BUCKET_ORDER_BY); + this.isLoadVideo = isLoadVideo; + this.isLoadImage = isLoadImage; + } + + public static CursorLoader create(Context context, Set mimeTypeSet, boolean isLoadVideo, boolean isLoadImage) { + int index = 0; + String mimeSelection = ""; + ArrayList arrayList = MimeType.getMimeTypeList(mimeTypeSet); + String[] selectionArgs = new String[arrayList.size()]; + for (String mimeType : arrayList) { + selectionArgs[index] = mimeType; + mimeSelection = String.format("%s =? OR %s", MIME_TYPE, mimeSelection); + index++; + } + + if (mimeSelection.endsWith(" OR ")) { + mimeSelection = mimeSelection.substring(0, mimeSelection.length() - 4); + } + String selection = "(" + MEDIA_TYPE + "=" + MEDIA_TYPE_VIDEO + " OR " + MEDIA_TYPE + "=" + MEDIA_TYPE_IMAGE + ")" + + " AND " + + SIZE + ">0" + + " AND (" + + mimeSelection + ")"; + return new MediaSetsLoader(context, selection, selectionArgs, isLoadVideo, isLoadImage); + } + + @Override + public Cursor loadInBackground() { + Cursor albums = super.loadInBackground(); + MatrixCursor allAlbum = new MatrixCursor(COLUMNS); + int totalCount = 0; + Uri allAlbumCoverUri = null; + LongSparseArray countMap = new LongSparseArray<>(); + if (albums != null) { + while (albums.moveToNext()) { + int columnIndex = albums.getColumnIndex(COLUMN_BUCKET_ID); + if (columnIndex >= 0) { + long bucketId = albums.getLong(columnIndex); + Long count = countMap.get(bucketId); + count = count == null ? 1L : (count + 1); + countMap.put(bucketId, count); + } + } + } + MatrixCursor newAlbums = new MatrixCursor(COLUMNS); + if (albums != null) { + if (albums.moveToFirst()) { + allAlbumCoverUri = getUri(albums); + Set done = new HashSet<>(); + do { + int columnIndex = albums.getColumnIndex(COLUMN_BUCKET_ID); + if (columnIndex < 0) { + continue; + } + long bucketId = albums.getLong(columnIndex); + if (done.contains(bucketId)) { + continue; + } + int columnIndexId = albums.getColumnIndex(_ID); + int columnIndexDisplayName = albums.getColumnIndex(COLUMN_BUCKET_DISPLAY_NAME); + if (columnIndexId < 0 || columnIndexDisplayName < 0) { + continue; + } + long fileId = albums.getLong(columnIndexId); + String bucketDisplayName = albums.getString(columnIndexDisplayName); + Uri uri = getUri(albums); + if (uri != null) { + long count = countMap.get(bucketId); + newAlbums.addRow(new String[]{ + Long.toString(fileId), + Long.toString(bucketId), + bucketDisplayName, + uri.toString(), + String.valueOf(count)}); + done.add(bucketId); + totalCount += (int) count; + } + } while (albums.moveToNext()); + } + } + + String name = ""; + if (isLoadImage && isLoadVideo) { + name = getContext().getString(R.string.picker_str_folder_item_all); + } else if (isLoadImage) { + name = getContext().getString(R.string.picker_str_folder_item_image); + } else if (isLoadVideo) { + name = getContext().getString(R.string.picker_str_folder_item_video); + } + + allAlbum.addRow(new String[]{ImageSet.ID_ALL_MEDIA, ImageSet.ID_ALL_MEDIA, name, + allAlbumCoverUri == null ? null : allAlbumCoverUri.toString(), + String.valueOf(totalCount)}); + + return new MergeCursor(new Cursor[]{allAlbum, newAlbums}); + } + + private static Uri getUri(Cursor cursor) { + int idColumnIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID); + int mimeTypeColumnIndex = cursor.getColumnIndex(MIME_TYPE); + if (idColumnIndex < 0 || mimeTypeColumnIndex < 0) { + return null; + } + long id = cursor.getLong(idColumnIndex); + String mimeType = cursor.getString(mimeTypeColumnIndex); + Uri contentUri; + if (MimeType.isImage(mimeType)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if (MimeType.isVideo(mimeType)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else { + contentUri = QUERY_URI; + } + return ContentUris.withAppendedId(contentUri, id); + } + + @Override + public void onContentChanged() { + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java new file mode 100644 index 0000000..865ef16 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java @@ -0,0 +1,40 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.net.Uri; +import android.provider.MediaStore; + +/** + * Time: 2019/10/29 20:38 + * Author:ypx + * Description: + */ +class MediaStoreConstants { + static final String MIME_TYPE = MediaStore.MediaColumns.MIME_TYPE; + static final String MEDIA_TYPE = MediaStore.Files.FileColumns.MEDIA_TYPE; + static final String DISPLAY_NAME = MediaStore.Files.FileColumns.DISPLAY_NAME; + static final int MEDIA_TYPE_VIDEO = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO; + static final int MEDIA_TYPE_IMAGE = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE; + static final String WIDTH = MediaStore.Files.FileColumns.WIDTH; + static final String HEIGHT = MediaStore.Files.FileColumns.HEIGHT; + static final String DATE_MODIFIED = MediaStore.Files.FileColumns.DATE_MODIFIED; + static final String DURATION = MediaStore.MediaColumns.DURATION; + static final String SIZE = MediaStore.MediaColumns.SIZE; + static final String _ID = MediaStore.Files.FileColumns._ID; + static final String COLUMN_BUCKET_ID = "bucket_id"; + static final String COLUMN_BUCKET_DISPLAY_NAME = "bucket_display_name"; + static final String COLUMN_URI = "uri"; + static final String COLUMN_COUNT = "count"; + static final String BUCKET_ORDER_BY = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; + /** + * android 10 已废弃此常量 + */ + static final String DATA = MediaStore.MediaColumns.DATA; + static final Uri QUERY_URI = MediaStore.Files.getContentUri("external"); + + /** + * @return 是否是Android10之前版本 + */ + static boolean isBeforeAndroidQ() { + return android.os.Build.VERSION.SDK_INT < 29; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java new file mode 100644 index 0000000..930eacf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java @@ -0,0 +1,18 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * Description: 图片选择器回调 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public interface OnImagePickCompleteListener extends Serializable { + void onImagePickComplete(ArrayList items); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java new file mode 100644 index 0000000..7c8c7ba --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java @@ -0,0 +1,14 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +/** + * Description: 图片选择器回调 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public interface OnImagePickCompleteListener2 extends OnImagePickCompleteListener { + void onPickFailed(PickerError error); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java new file mode 100644 index 0000000..f18c539 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java @@ -0,0 +1,41 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +import java.util.ArrayList; + +/** + * Time: 2019/10/27 22:02 + * Author:ypx + * Description: 类型回调类,调用者可自己定制回调的返回类型 + */ +public abstract class OnPickerCompleteListener implements OnImagePickCompleteListener2 { + + /** + * 默认回调出来的是 ArrayList 类型,调用者自己实现类型间转化 + * + * @param items 选择器回调 + * @return 用户自己类型 + */ + public abstract T onTransit(ArrayList items); + + /** + * 选择器完成回调 + * + * @param t 回调类型 + */ + public abstract void onPickComplete(T t); + + @Override + public void onPickFailed(PickerError error) { + + } + + @Override + public void onImagePickComplete(ArrayList items) { + onPickComplete(onTransit(items)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java new file mode 100644 index 0000000..1a0aba9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java @@ -0,0 +1,36 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +import java.util.ArrayList; + +/** + * Time: 2019/10/27 21:26 + * Author:ypx + * Description: OnPickerCompleteListener 子类,实现了String 类型回调 + */ +public abstract class OnStringCompleteListener extends OnPickerCompleteListener { + + public abstract void onPickComplete(String path); + + @Override + public String onTransit(ArrayList items) { + if (items.size() > 0 && items.get(0) != null) { + return items.get(0).path; + } + return null; + } + + @Override + public void onPickFailed(PickerError error) { + + } + + @Override + public void onImagePickComplete(ArrayList items) { + onPickComplete(onTransit(items)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java new file mode 100644 index 0000000..9254490 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java @@ -0,0 +1,37 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +import java.util.ArrayList; + +/** + * Time: 2019/10/27 21:26 + * Author:ypx + * Description:OnPickerCompleteListener子类,实现了ArrayList 回调 + */ +public abstract class OnStringListCompleteListener extends OnPickerCompleteListener> { + + public abstract void onPickComplete(ArrayList list); + + @Override + public void onPickFailed(PickerError error) { + + } + + @Override + public void onImagePickComplete(ArrayList items) { + onPickComplete(onTransit(items)); + } + + @Override + public ArrayList onTransit(ArrayList items) { + ArrayList list = new ArrayList<>(); + for (ImageItem item : items) { + list.add(item.path); + } + return list; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java new file mode 100644 index 0000000..1585aa4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java @@ -0,0 +1,42 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.content.Intent; + + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; + +import java.util.ArrayList; + +/** + * Time: 2019/11/6 17:35 + * Author:ypx + * Description:选择器activityResult处理类 + */ +public class PickerActivityCallBack implements PLauncher.Callback { + private OnImagePickCompleteListener listener; + + public static PickerActivityCallBack create(OnImagePickCompleteListener listener) { + return new PickerActivityCallBack(listener); + } + + private PickerActivityCallBack(OnImagePickCompleteListener listener) { + this.listener = listener; + } + + @Override + public void onActivityResult(int resultCode, Intent data) { + if (listener != null + && resultCode == ImagePicker.REQ_PICKER_RESULT_CODE + && data.hasExtra(ImagePicker.INTENT_KEY_PICKER_RESULT)) { + ArrayList list = (ArrayList) data.getSerializableExtra(ImagePicker.INTENT_KEY_PICKER_RESULT); + listener.onImagePickComplete(list); + } else if (listener instanceof OnImagePickCompleteListener2) { + if (resultCode == 0) { + resultCode = PickerError.CANCEL.getCode(); + } + ((OnImagePickCompleteListener2) listener).onPickFailed(PickerError.valueOf(resultCode)); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java new file mode 100644 index 0000000..edc172f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java @@ -0,0 +1,6 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +public enum ProgressSceneEnum { + loadMediaItem, + crop +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java new file mode 100644 index 0000000..47d2c1b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java @@ -0,0 +1,144 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.UriPathInfo; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil; +import com.remax.visualnovel.widget.imagepicker.utils.PPermissionUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PSingleMediaScanner; +import com.remax.visualnovel.widget.imagepicker.utils.PickerFileProvider; + +import java.io.File; +import java.util.ArrayList; + +public class CameraCompat { + + + /** + * 兼容安卓10拍照.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity 调用拍照的页面 + * @param imageName 图片名称 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 拍照回调 + */ + public static void takePhoto(final Activity activity, + final String imageName, + final boolean isCopyInDCIM, + final OnImagePickCompleteListener listener) { + final String path = PBitmapUtils.getPickerFileDirectory(activity).getAbsolutePath() + + File.separator + imageName + ".jpg"; + if (!PPermissionUtils.hasCameraPermissions(activity) || listener == null) { + return; + } + final Uri imageUri = PickerFileProvider.getUriForFile(activity, new File(path)); + PLauncher.init(activity).startActivityForResult(getTakePhotoIntent(activity, imageUri), (resultCode, data) -> { + if (resultCode != Activity.RESULT_OK || path.trim().isEmpty()) { + PickerErrorExecutor.executeError(listener, PickerError.TAKE_PHOTO_FAILED.getCode()); + return; + } + UriPathInfo uriPathInfo; + if (isCopyInDCIM) { + uriPathInfo = PBitmapUtils.copyFileToDCIM(activity, path, imageName, MimeType.JPEG); + PSingleMediaScanner.refresh(activity, uriPathInfo.absolutePath, null); + } else { + uriPathInfo = new UriPathInfo(imageUri, path); + } + + ImageItem item = new ImageItem(); + item.path = uriPathInfo.absolutePath; + item.mimeType = MimeType.JPEG.toString(); + item.setUriPath(uriPathInfo.uri.toString()); + item.time = System.currentTimeMillis(); + int[] size = PBitmapUtils.getImageWidthHeight(path); + item.width = size[0]; + item.height = size[1]; + item.mimeType = MimeType.JPEG.toString(); + ArrayList list = new ArrayList<>(); + list.add(item); + listener.onImagePickComplete(list); + }); + } + + /** + * 兼容安卓10拍摄视频.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity activity + * @param videoName 视频保存路径 + * @param maxDuration 视频最大时长 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 视频回调 + */ + public static void takeVideo(final Activity activity, + final String videoName, + long maxDuration, + final boolean isCopyInDCIM, + final OnImagePickCompleteListener listener) { + if (!PPermissionUtils.hasCameraPermissions(activity) || listener == null) { + return; + } + final String path = PBitmapUtils.getPickerFileDirectory(activity).getAbsolutePath() + + File.separator + videoName + ".mp4"; + final Uri videoUri = PickerFileProvider.getUriForFile(activity, new File(path)); + PLauncher.init(activity).startActivityForResult(getTakeVideoIntent(activity, videoUri, maxDuration), (resultCode, data) -> { + if (resultCode != Activity.RESULT_OK || path.trim().isEmpty()) { + PickerErrorExecutor.executeError(listener, PickerError.TAKE_PHOTO_FAILED.getCode()); + return; + } + UriPathInfo uriPathInfo; + if (isCopyInDCIM) { + uriPathInfo = PBitmapUtils.copyFileToDCIM(activity, path, videoName, MimeType.MP4); + PSingleMediaScanner.refresh(activity, uriPathInfo.absolutePath, null); + } else { + uriPathInfo = new UriPathInfo(videoUri, path); + } + + ImageItem item = new ImageItem(); + item.path = uriPathInfo.absolutePath; + item.setUriPath(uriPathInfo.uri.toString()); + item.time = System.currentTimeMillis(); + item.mimeType = MimeType.MP4.toString(); + item.setVideo(true); + item.duration = PBitmapUtils.getLocalVideoDuration(path); + item.setDurationFormat(PDateUtil.getVideoDuration(item.duration)); + ArrayList list = new ArrayList<>(); + list.add(item); + listener.onImagePickComplete(list); + }); + } + + private static Intent getTakePhotoIntent(Activity activity, Uri imageUri) { + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + + intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + return intent; + } + + private static Intent getTakeVideoIntent(Activity activity, Uri imageUri, long maxDuration) { + Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri); + if (maxDuration > 1) { + intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, maxDuration / 1000L); + } + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + return intent; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java new file mode 100644 index 0000000..88d26c6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java @@ -0,0 +1,169 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageCropMode; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Time: 2019/9/30 9:45 + * Author:ypx + * Description: 剪裁View封装 + */ +public class CropViewContainerHelper { + private WeakReference parentReference; + //存储已选择的剪裁View + private HashMap cropViewList = new HashMap<>(); + + public CropViewContainerHelper(@NonNull ViewGroup parent) { + parentReference = new WeakReference<>(parent); + } + + private ViewGroup getParent() { + if (parentReference != null && parentReference.get() != null) { + return parentReference.get(); + } + return null; + } + + public void setBackgroundColor(int color) { + if (mCropView != null) { + mCropView.setBackgroundColor(color); + } + } + + private CropImageView mCropView; + + public CropImageView loadCropView(final Context context, final ImageItem imageItem, final int mCropSize, + final IPickerPresenter presenter, final onLoadComplete loadComplete) { + final Activity activity = (Activity) context; + if (cropViewList.containsKey(imageItem) && cropViewList.get(imageItem) != null) { + mCropView = cropViewList.get(imageItem); + } else { + mCropView = new CropImageView(context); + //设置剪裁view的属性 + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + mCropView.enable(); // 启用图片缩放功能 + mCropView.setMaxScale(3.0f); + mCropView.setCanShowTouchLine(true); + mCropView.setShowImageRectLine(true); + if (imageItem.width == 0 || imageItem.height == 0) { + mCropView.setOnImageLoadListener(new CropImageView.onImageLoadListener() { + @Override + public void onImageLoaded(float w, float h) { + imageItem.width = (int) w; + imageItem.height = (int) h; + if (loadComplete != null) { + loadComplete.loadComplete(); + } + } + }); + } + + + DetailImageLoadHelper.displayDetailImage(true, mCropView, presenter, imageItem); + } + + if (getParent() != null) { + getParent().removeAllViews(); + if (mCropView.getParent() != null) { + ((ViewGroup) mCropView.getParent()).removeView(mCropView); + } + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(mCropSize, mCropSize); + params.gravity = Gravity.CENTER; + getParent().addView(mCropView, params); + } + return mCropView; + } + + public interface onLoadComplete { + void loadComplete(); + } + + public void addCropView(CropImageView view, ImageItem imageItem) { + if (!cropViewList.containsKey(imageItem)) { + cropViewList.put(imageItem, view); + } + } + + public void removeCropView(ImageItem imageItem) { + cropViewList.remove(imageItem); + } + + public void refreshAllState(ImageItem currentImageItem, List selectList, + ViewGroup invisibleContainer, + boolean isFitState, + ResetSizeExecutor executor) { + invisibleContainer.removeAllViews(); + invisibleContainer.setVisibility(View.VISIBLE); + for (ImageItem imageItem : selectList) { + if (imageItem == currentImageItem) { + continue; + } + CropImageView picBrowseImageView = cropViewList.get(imageItem); + if (picBrowseImageView != null) { + invisibleContainer.addView(picBrowseImageView); + if (executor != null) { + executor.resetAllCropViewSize(picBrowseImageView); + } + if (isFitState) { + imageItem.setCropMode(ImageCropMode.ImageScale_FILL); + picBrowseImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + } + cropViewList.put(imageItem, picBrowseImageView); + } + } + invisibleContainer.setVisibility(View.INVISIBLE); + } + + public ArrayList generateCropUrls(List selectList, int cropMode) { + for (ImageItem imageItem : selectList) { + CropImageView view = cropViewList.get(imageItem); + if (view == null) { + continue; + } + view.requestLayout(); + Bitmap bitmap; + if (imageItem.getCropMode() == ImageCropMode.ImageScale_GAP) { + bitmap = view.generateCropBitmapFromView(Color.WHITE); + } else { + bitmap = view.generateCropBitmap(); + } + String cropUrl = PBitmapUtils.saveBitmapToFile(view.getContext(), bitmap, + "crop_" + System.currentTimeMillis(), + Bitmap.CompressFormat.JPEG); + if (imageItem.getCropUrl() != null && imageItem.getCropUrl().length() > 0) { + new File(imageItem.getCropUrl()).delete(); + } + imageItem.setCropUrl(cropUrl); + imageItem.setCropMode(cropMode); + imageItem.setPress(false); + } + return (ArrayList) selectList; + } + + + public interface ResetSizeExecutor { + void resetAllCropViewSize(CropImageView view); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java new file mode 100644 index 0000000..8d048a9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java @@ -0,0 +1,24 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.widget.ImageView; + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + + +public class DetailImageLoadHelper { + + public static void displayDetailImage(boolean isCrop, final ImageView imageView, final IPickerPresenter presenter, final ImageItem imageItem) { + if (presenter != null) { + //剪裁不压缩,大图预览尺寸超过2K的图片需要压缩,不能使用ARGB-8888加载,滑动会卡顿,并且浪费内存, + // 其实最好的做法是分段加载,但是cropImageView在支持剪裁的基础上不能支持分段加载 + if (isCrop || ImagePicker.isPreviewWithHighQuality()) { + presenter.displayImage(imageView, imageItem, imageView.getWidth(), false); + } else { + presenter.displayImage(imageView, imageItem, imageView.getWidth(), imageItem.isOver2KImage()); + } + + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java new file mode 100644 index 0000000..e19ea17 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java @@ -0,0 +1,29 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.app.Activity; + +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; + + +/** + * Time: 2019/10/18 9:53 + * Author:ypx + * Description: 调用选择器失败回调 + */ +public class PickerErrorExecutor { + + public static void executeError(Activity activity, int code) { + if (activity != null) { + activity.setResult(code); + activity.finish(); + } + } + + public static void executeError(OnImagePickCompleteListener listener, int code) { + if (listener instanceof OnImagePickCompleteListener2) { + ((OnImagePickCompleteListener2) listener).onPickFailed(PickerError.valueOf(code)); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java new file mode 100644 index 0000000..2cd2f85 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java @@ -0,0 +1,339 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.animation.ValueAnimator; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.widget.TouchRecyclerView; + + +/** + * Description: 滑动辅助类 + *

+ * Author: peixing.yang + * Date: 2019/2/26 + */ +public class RecyclerViewTouchHelper { + private TouchRecyclerView recyclerView; + private View topView; + private View maskView; + private boolean isScrollTopView = false; + private boolean isTopViewStick = false; + private int canScrollHeight; + private int stickHeight; + + public static RecyclerViewTouchHelper create(TouchRecyclerView recyclerView) { + return new RecyclerViewTouchHelper(recyclerView); + } + + private RecyclerViewTouchHelper(TouchRecyclerView recyclerView) { + this.recyclerView = recyclerView; + } + + public RecyclerViewTouchHelper setTopView(View topView) { + this.topView = topView; + return this; + } + + public RecyclerViewTouchHelper setMaskView(View maskView) { + this.maskView = maskView; + return this; + } + + public RecyclerViewTouchHelper setCanScrollHeight(int canScrollHeight) { + this.canScrollHeight = canScrollHeight; + return this; + } + + public RecyclerViewTouchHelper setStickHeight(int stickHeight) { + this.stickHeight = stickHeight; + return this; + } + + private void setRecyclerViewPaddingTop(int top) { + recyclerView.setPadding(recyclerView.getPaddingStart(), top, + recyclerView.getPaddingEnd(), recyclerView.getPaddingBottom()); + } + + private int lastScrollY = 0; + + public RecyclerViewTouchHelper build() { + setRecyclerViewPaddingTop(canScrollHeight + stickHeight); + recyclerView.post(new Runnable() { + @Override + public void run() { + setRecyclerViewPaddingTop(topView.getHeight()); + } + }); + recyclerView.setTouchView(topView); + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + if (isImageGridCantScroll()) { + return; + } + int scrollY = getScrollYDistance(); + if (isScrollTopView && topView.getTranslationY() != -canScrollHeight) { + if (lastScrollY == 0) { + lastScrollY = scrollY; + } + int distance = scrollY - lastScrollY; + if (distance >= canScrollHeight) { + setMaskAlpha(1); + topView.setTranslationY(-canScrollHeight); + setRecyclerViewPaddingTop(stickHeight); + } else { + if (distance <= 0) { + setMaskAlpha(0); + topView.setTranslationY(0); + } else { + float ratio = -distance * 1.00f / (-canScrollHeight * 1.00f); + setMaskAlpha(ratio); + topView.setTranslationY(-distance); + } + } + return; + } + if (isTopViewFullShow()) { + isTopViewStick = false; + setMaskAlpha(0); + } + + if (isTopViewStick) { + int translate = -scrollY - topView.getHeight(); + if (translate <= -canScrollHeight) { + topView.setTranslationY(-canScrollHeight); + setRecyclerViewPaddingTop(stickHeight); + isTopViewStick = false; + } else { + if (translate >= -20) { + translate = 0; + } + topView.setTranslationY(translate); + float ratio = topView.getTranslationY() * 1.00f / (-topView.getHeight() * 1.00f); + setMaskAlpha(ratio); + } + } + } + }); + + recyclerView.setDragScrollListener(new TouchRecyclerView.onDragScrollListener() { + @Override + public void onScrollOverTop(int distance) { + if (isImageGridCantScroll()) { + return; + } + isScrollTopView = true; + + } + + @Override + public void onScrollDown(int distance) { + if (isImageGridCantScroll()) { + return; + } + if (isRecyclerViewScrollToTop() && !isScrollTopView) { + setRecyclerViewPaddingTop(topView.getHeight()); + isTopViewStick = true; + } + } + + @Override + public void onScrollUp() { + lastScrollY = 0; + if (isImageGridCantScroll()) { + return; + } + if (isScrollTopView) { + transitTopWithAnim(!isRecyclerViewCanScrollOverScreen(), -1, true); + } else { + if (isTopViewStick && !isTopViewFullShow()) { + reset(); + } + } + isScrollTopView = false; + } + }); + return this; + } + + private boolean isRecyclerViewScrollToTop() { + return !recyclerView.canScrollVertically(-1); + } + + private boolean isRecyclerViewScrollToBottom() { + return !recyclerView.canScrollVertically(1); + } + + private boolean isRecyclerViewCanScrollOverScreen() { + if (isImageGridCantScroll()) { + return false; + } + int count = 0; + if (recyclerView.getAdapter() != null) { + count = recyclerView.getAdapter().getItemCount(); + } + int itemHeight = getItemHeight(); + if (count < getSpanCount()) { + return false; + } + int lineCount = count % getSpanCount() == 0 ? count / getSpanCount() : count / getSpanCount() + 1; + return lineCount * itemHeight + recyclerView.getPaddingBottom() > + PViewSizeUtils.getScreenHeight(recyclerView.getContext()) - stickHeight; + } + + private int spanCount = 0; + + private int getSpanCount() { + if (spanCount != 0) { + return spanCount; + } + GridLayoutManager gridLayoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + if (gridLayoutManager != null) { + spanCount = gridLayoutManager.getSpanCount(); + return spanCount; + } + return 0; + } + + /** + * 设置剪裁区域阴影 + * + * @param ratio 阴影比例 + */ + private void setMaskAlpha(float ratio) { + maskView.setVisibility(View.VISIBLE); + if (ratio <= 0) { + ratio = 0; + maskView.setVisibility(View.GONE); + } else if (ratio >= 1) { + ratio = 1; + } + maskView.setAlpha(ratio); + } + + /** + * 剪裁区域+标题栏 是否完整显示 + */ + private boolean isTopViewFullShow() { + return (topView.getTranslationY() == 0); + } + + + /** + * 选择图片recyclerView是否不可以滑动(数量少) + */ + private boolean isImageGridCantScroll() { + return !recyclerView.canScrollVertically(1) && + !recyclerView.canScrollVertically(-1); + } + + + /** + * 获取recyclerView滑动距离 + */ + private int getScrollYDistance() { + if (!(recyclerView.getLayoutManager() instanceof GridLayoutManager)) { + return 0; + } + GridLayoutManager gridLayoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + int position = gridLayoutManager.findFirstVisibleItemPosition(); + if (position < 0) { + position = 0; + } + View firstVisibleChildView = gridLayoutManager.findViewByPosition(position); + if (firstVisibleChildView == null) { + return 0; + } + + int itemHeight = firstVisibleChildView.getHeight() + PViewSizeUtils.dp(recyclerView.getContext(), 2); + return (position / getSpanCount()) * itemHeight - firstVisibleChildView.getTop(); + } + + private int getItemHeight() { + if (!(recyclerView.getLayoutManager() instanceof GridLayoutManager)) { + return 0; + } + GridLayoutManager gridLayoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + int position = gridLayoutManager.findFirstVisibleItemPosition(); + if (position < 0) { + position = 0; + } + View firstVisibleChildView = gridLayoutManager.findViewByPosition(position); + if (firstVisibleChildView == null) { + return 0; + } + return firstVisibleChildView.getHeight(); + } + + private void reset() { + final int scrollY = getScrollYDistance(); + if (scrollY == 0) { + return; + } + ValueAnimator anim = ValueAnimator.ofFloat(0.0f, 1.0f); + anim.setDuration(500); + anim.setInterpolator(new AccelerateDecelerateInterpolator()); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float ratio = (Float) animation.getAnimatedValue(); + recyclerView.scrollBy(0, (int) (scrollY * ratio)); + } + }); + anim.start(); + } + + /** + * 动画控制是否展开还是完整显示topView + * + * @param isFocusShow 是否强制完全展示 + * @param scrollToPosition 滑动到制定的位置 + */ + public void transitTopWithAnim(boolean isFocusShow, final int scrollToPosition, boolean isShowTransit) { + if (!isShowTransit) { + return; + } + if (isTopViewFullShow()) { + return; + } + final int startTop = (int) topView.getTranslationY(); + //如果滑动区域小于标题栏高度的一半,则完全展示,否则收回剪裁区域到顶部 + final int endTop = (isFocusShow || (startTop > -stickHeight / 2)) ? 0 : -canScrollHeight; + final int startPadding = recyclerView.getPaddingTop(); + final float startAlpha = maskView.getAlpha(); + ValueAnimator anim = ValueAnimator.ofFloat(0.0f, 1.0f); + anim.setDuration(300); + anim.setInterpolator(new AccelerateDecelerateInterpolator()); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float ratio = (Float) animation.getAnimatedValue(); + int dis = (int) ((endTop - startTop) * ratio + startTop); + topView.setTranslationY(dis); + float maskAlpha = endTop == 0 ? (-startAlpha) * ratio + startAlpha : (1 - startAlpha) * ratio + startAlpha; + setMaskAlpha(maskAlpha); + int padding = (int) (((endTop == 0 ? topView.getHeight() : stickHeight) - startPadding) * ratio + startPadding); + setRecyclerViewPaddingTop(padding); + if (ratio == 1.0f) { + if (scrollToPosition == 0) { + recyclerView.scrollToPosition(0); + } else if (scrollToPosition != -1) { + recyclerView.smoothScrollToPosition(scrollToPosition); + } + } + } + }); + anim.start(); + } + + public int dp(int dp) { + return PViewSizeUtils.dp(recyclerView.getContext(), dp); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java new file mode 100644 index 0000000..521a4b6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java @@ -0,0 +1,122 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.content.Context; +import android.graphics.Color; +import android.media.MediaPlayer; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.VideoView; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; + + +/** + * Time: 2019/9/30 9:45 + * Author:ypx + * Description: 视频播放 + */ +public class VideoViewContainerHelper { + private VideoView videoView; + private ImageView previewImg; + private ImageView pauseImg; + + public void loadVideoView(ViewGroup parent, ImageItem imageItem, IPickerPresenter presenter, PickerUiConfig uiConfig) { + Context context = parent.getContext(); + + if (videoView == null) { + videoView = new VideoView(context); + videoView.setBackgroundColor(Color.TRANSPARENT); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + params.gravity = Gravity.CENTER; + videoView.setLayoutParams(params); + + previewImg = new ImageView(context); + previewImg.setLayoutParams(params); + previewImg.setScaleType(ImageView.ScaleType.FIT_CENTER); + + pauseImg = new ImageView(context); + pauseImg.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + pauseImg.setImageDrawable(context.getResources().getDrawable(uiConfig.getVideoPauseIconID())); + FrameLayout.LayoutParams params2 = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + params2.gravity = Gravity.CENTER; + pauseImg.setLayoutParams(params2); + } + pauseImg.setVisibility(View.GONE); + parent.removeAllViews(); + parent.addView(videoView); + parent.addView(previewImg); + parent.addView(pauseImg); + previewImg.setVisibility(View.VISIBLE); + presenter.displayImage(previewImg, imageItem, 0, false); + videoView.setVideoPath(imageItem.path); + videoView.start(); + //监听视频播放完的代码 + videoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mPlayer) { + mPlayer.start(); + mPlayer.setLooping(true); + } + }); + + videoView.setOnClickListener(v -> { + if (videoView.isPlaying()) { + onPause(); + } else { + onResume(); + } + }); + + videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + mp.setOnSeekCompleteListener(new MediaPlayer.OnSeekCompleteListener() { + @Override + public void onSeekComplete(MediaPlayer mp) { + videoView.start(); + } + }); + mp.setOnInfoListener(new MediaPlayer.OnInfoListener() { + @Override + public boolean onInfo(MediaPlayer mp, int what, int extra) { + if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) { + // video 视屏播放的时候把背景设置为透明 + videoView.setBackgroundColor(Color.TRANSPARENT); + previewImg.setVisibility(View.GONE); + return true; + } + return false; + } + }); + } + }); + } + + public void onResume() { + if (videoView != null && pauseImg != null) { + videoView.start(); + videoView.seekTo(videoView.getCurrentPosition()); + pauseImg.setVisibility(View.GONE); + } + } + + public void onPause() { + if (videoView != null && pauseImg != null) { + videoView.pause(); + pauseImg.setVisibility(View.VISIBLE); + } + } + + public void onDestroy() { + if (videoView != null) { + videoView.suspend();//将VideoView所占用的资源释放掉 + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java new file mode 100644 index 0000000..0126cfb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java @@ -0,0 +1,106 @@ +package com.remax.visualnovel.widget.imagepicker.helper.launcher; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; + + +/** + * Activity跳转封装类,把OnActivityResult方式改为Callback方式 + *

+ * Created by XiaoFeng on 2018/9/5. + */ +public class PLauncher { + + private static final String TAG = "PLauncher"; + private Context mContext; + /** + * V4兼容包下的Fragment + */ + private PRouterV4 mRouterFragmentV4; + /** + * 标准SDK下的Fragment + */ + private PRouter mRouterFragment; + + public static PLauncher init(Fragment fragment) { + return init(fragment.getActivity()); + } + + public static PLauncher init(FragmentActivity activity) { + return new PLauncher(activity); + } + + public static PLauncher init(Activity activity) { + return new PLauncher(activity); + } + + private PLauncher(FragmentActivity activity) { + mContext = activity; + mRouterFragmentV4 = getRouterFragmentV4(activity); + } + + private PLauncher(Activity activity) { + mContext = activity; + mRouterFragment = getRouterFragment(activity); + } + + private PRouterV4 getRouterFragmentV4(FragmentActivity activity) { + PRouterV4 routerFragment = findRouterFragmentV4(activity); + if (routerFragment == null) { + routerFragment = PRouterV4.newInstance(); + FragmentManager fragmentManager = activity.getSupportFragmentManager(); + fragmentManager + .beginTransaction() + .add(routerFragment, TAG) + .commitAllowingStateLoss(); + fragmentManager.executePendingTransactions(); + } + return routerFragment; + } + + private PRouterV4 findRouterFragmentV4(FragmentActivity activity) { + return (PRouterV4) activity.getSupportFragmentManager().findFragmentByTag(TAG); + } + + private PRouter getRouterFragment(Activity activity) { + PRouter routerFragment = findRouterFragment(activity); + if (routerFragment == null) { + routerFragment = PRouter.newInstance(); + android.app.FragmentManager fragmentManager = activity.getFragmentManager(); + fragmentManager + .beginTransaction() + .add(routerFragment, TAG) + .commitAllowingStateLoss(); + fragmentManager.executePendingTransactions(); + } + return routerFragment; + } + + private PRouter findRouterFragment(Activity activity) { + return (PRouter) activity.getFragmentManager().findFragmentByTag(TAG); + } + + public void startActivityForResult(Class clazz, Callback callback) { + Intent intent = new Intent(mContext, clazz); + startActivityForResult(intent, callback); + } + + public void startActivityForResult(Intent intent, Callback callback) { + if (mRouterFragmentV4 != null) { + mRouterFragmentV4.startActivityForResult(intent, callback); + } else if (mRouterFragment != null) { + mRouterFragment.startActivityForResult(intent, callback); + } else { + throw new RuntimeException("please do init first!"); + } + } + + public interface Callback { + void onActivityResult(int resultCode, Intent data); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java new file mode 100644 index 0000000..bfc5a45 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java @@ -0,0 +1,72 @@ +package com.remax.visualnovel.widget.imagepicker.helper.launcher; + +import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.os.Bundle; +import android.util.SparseArray; + + +import java.util.Random; + +import timber.log.Timber; + +/** + * 把OnActivityResult方式转换为Callback方式的空Fragment(标准SDK) + *

+ * Created by XiaoFeng on 2018/9/5. + */ +public class PRouter extends Fragment { + + private SparseArray mCallbacks = new SparseArray<>(); + private Random mCodeGenerator = new Random(); + + public PRouter() { + // Required empty public constructor + } + + public static PRouter newInstance() { + return new PRouter(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + public void startActivityForResult(Intent intent, PLauncher.Callback callback) { + try { + int requestCode = makeRequestCode(); + mCallbacks.put(requestCode, callback); + startActivityForResult(intent, requestCode); + } catch (ActivityNotFoundException ex) { + Timber.e(ex); + } + } + + /** + * 随机生成唯一的requestCode,最多尝试10次 + * + * @return + */ + private int makeRequestCode() { + int requestCode; + int tryCount = 0; + do { + requestCode = mCodeGenerator.nextInt(0x0000FFFF); + tryCount++; + } while (mCallbacks.indexOfKey(requestCode) >= 0 && tryCount < 10); + return requestCode; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + PLauncher.Callback callback = mCallbacks.get(requestCode); + mCallbacks.remove(requestCode); + if (callback != null) { + callback.onActivityResult(resultCode, data); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java new file mode 100644 index 0000000..8e47280 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java @@ -0,0 +1,66 @@ +package com.remax.visualnovel.widget.imagepicker.helper.launcher; + +import android.content.Intent; +import android.os.Bundle; +import android.util.SparseArray; + +import androidx.fragment.app.Fragment; + + +import java.util.Random; + +/** + * 把OnActivityResult方式转换为Callback方式的空Fragment(V4兼容包) + * + * Created by XiaoFeng on 2018/9/5. + */ +public class PRouterV4 extends Fragment { + + private SparseArray mCallbacks = new SparseArray<>(); + private Random mCodeGenerator = new Random(); + + public PRouterV4() { + // Required empty public constructor + } + + public static PRouterV4 newInstance() { + return new PRouterV4(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + public void startActivityForResult(Intent intent, PLauncher.Callback callback) { + int requestCode = makeRequestCode(); + mCallbacks.put(requestCode, callback); + startActivityForResult(intent, requestCode); + } + + /** + * 随机生成唯一的requestCode,最多尝试10次 + * + * @return + */ + private int makeRequestCode() { + int requestCode; + int tryCount = 0; + do { + requestCode = mCodeGenerator.nextInt(0x0000FFFF); + tryCount++; + } while (mCallbacks.indexOfKey(requestCode) >= 0 && tryCount < 10); + return requestCode; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + PLauncher.Callback callback = mCallbacks.get(requestCode); + mCallbacks.remove(requestCode); + if (callback != null) { + callback.onActivityResult(resultCode, data); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java new file mode 100644 index 0000000..ed00a1c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 Paul Burke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper; + +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Interface to listen for a move or dismissal event from a {@link ItemTouchHelper.Callback}. + * + * @author Paul Burke (ipaulpro) + */ +public interface ItemTouchHelperAdapter { + + /** + * Called when an item has been dragged far enough to trigger a move. This is called every time + * an item is shifted, and not at the end of a "drop" event.
+ *
+ * Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after + * adjusting the underlying data to reflect this move. + * + * @param fromPosition The start position of the moved item. + * @param toPosition Then resolved position of the moved item. + * @return True if the item was moved to the new adapter position. + * @see RecyclerView.ViewHolder#getAdapterPosition() + */ + boolean onItemMove(int fromPosition, int toPosition); + + /** + * Called when an item has been dismissed by a swipe.
+ *
+ * Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after + * adjusting the underlying data to reflect this removal. + * + * @param position The position of the item dismissed. + * @see RecyclerView.ViewHolder#getAdapterPosition() + */ + void onItemDismiss(int position); + + /** + * 是否可以移动 + * + * @return + */ + boolean isItemViewSwipeEnabled(); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java new file mode 100644 index 0000000..6e42d61 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 Paul Burke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper; + +import androidx.recyclerview.widget.ItemTouchHelper; + +/** + * Interface to notify an item ViewHolder of relevant callbacks from {@link + * ItemTouchHelper.Callback}. + * + * @author Paul Burke (ipaulpro) + */ +public interface ItemTouchHelperViewHolder { + + /** + * Called when the {@link ItemTouchHelper} first registers an item as being moved or swiped. + * Implementations should update the item view to indicate it's active state. + */ + void onItemSelected(); + + + /** + * Called when the {@link ItemTouchHelper} has completed the move or swipe, and the active item + * state should be cleared. + */ + void onItemClear(); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java new file mode 100644 index 0000000..a4a8a1b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2015 Paul Burke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper; + +import android.graphics.Canvas; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + + + + +/** + * An implementation of {@link ItemTouchHelper.Callback} that enables basic drag & drop and + * swipe-to-dismiss. Drag events are automatically started by an item long-press.
+ *
+ * Expects the RecyclerView.Adapter to listen for {@link + * ItemTouchHelperAdapter} callbacks and the RecyclerView.ViewHolder to implement + * {@link ItemTouchHelperViewHolder}. + * + * @author Paul Burke (ipaulpro) + */ +public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback { + public static final float ALPHA_FULL = 1.0f; + private boolean moveFreely = false; + private boolean lastActive = false; + // 移动时,item 的放大系数 + private float moveScaleFactor = 1.1f; + private final ItemTouchHelperAdapter mAdapter; + private OnSelectChangedListener mOnSelectChangedListener; + + public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) { + mAdapter = adapter; + } + + public void setOnSelectChangedListener(OnSelectChangedListener mOnSelectChangedListener) { + this.mOnSelectChangedListener = mOnSelectChangedListener; + } + + public void setMoveScaleFactor(float moveScaleFactor) { + this.moveScaleFactor = moveScaleFactor; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return mAdapter.isItemViewSwipeEnabled(); + } + + public interface OnSelectChangedListener { + /** + * @param viewHolder + * @param dX + * @param dY + * @param actionState + * @param isCurrentlyActive + */ + void onSelectedChanged(RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive); + + /** + * @param viewHolder + * @param actionState + */ + void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState); + } + + @Override + public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + // Set movement flags based on the layout manager + if (moveFreely) { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } else if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + final int swipeFlags = 0; + return makeMovementFlags(dragFlags, swipeFlags); + } else if (recyclerView.getLayoutManager() instanceof LinearLayoutManager) { + LinearLayoutManager linear = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (linear.getOrientation() == RecyclerView.HORIZONTAL) { + final int dragFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } else { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } + } else { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + // Notify the adapter of the move + mAdapter.onItemMove(source.getAdapterPosition(), target.getAdapterPosition()); + return true; + } + + @Override + public void onMoved(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, int fromPos, RecyclerView.ViewHolder target, int toPos, int x, int y) { + super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y); + viewHolder.itemView.setAlpha(1f); + target.itemView.setAlpha(1f); + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) { + // Notify the adapter of the dismissal + mAdapter.onItemDismiss(viewHolder.getAdapterPosition()); + } + + @Override + public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + // Fade out the view as it is swiped out of the parent's bounds + final float alpha = ALPHA_FULL - Math.abs(dX) / (float) viewHolder.itemView.getWidth(); + viewHolder.itemView.setAlpha(alpha); + viewHolder.itemView.setTranslationX(dX); + } else if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + if (isCurrentlyActive) { + viewHolder.itemView.setAlpha(0.5f); + viewHolder.itemView.setScaleX(moveScaleFactor); + viewHolder.itemView.setScaleY(moveScaleFactor); + } else { + viewHolder.itemView.setAlpha(1f); + viewHolder.itemView.setScaleX(1f); + viewHolder.itemView.setScaleY(1f); + } + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } else { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } + + // 从拖动到释放的过程 + if (null != mOnSelectChangedListener && !isCurrentlyActive && lastActive) { + mOnSelectChangedListener.onSelectedChanged(viewHolder, dX, dY, actionState, isCurrentlyActive); + } + lastActive = isCurrentlyActive; + } + + @Override + public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { + // We only want the active item to change + if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) { + if (viewHolder instanceof ItemTouchHelperViewHolder) { + // Let the view holder know that this item is being moved or dragged + ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder; + itemViewHolder.onItemSelected(); + } + } + super.onSelectedChanged(viewHolder, actionState); + + if (null != mOnSelectChangedListener) { + mOnSelectChangedListener.onSelectedChanged(viewHolder, actionState); + } + } + + @Override + public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.setAlpha(ALPHA_FULL); + if (viewHolder instanceof ItemTouchHelperViewHolder) { + // Tell the view holder it's time to restore the idle state + ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder; + itemViewHolder.onItemClear(); + } + } + + public void setMoveFreely(boolean moveFreely) { + this.moveFreely = moveFreely; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java new file mode 100644 index 0000000..8576f58 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java @@ -0,0 +1,168 @@ +package com.remax.visualnovel.widget.imagepicker.presenter; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +import com.remax.visualnovel.widget.imagepicker.adapter.PickerItemAdapter; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.ICameraExecutor; +import com.remax.visualnovel.widget.imagepicker.data.IReloadExecutor; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * Time: 2019/10/27 22:22 + * Author:ypx + * Description: 选择器交互接口类 + * + *

+ * 实现该接口可快速定制属于你自己的选择器样式。该接口支持如下操作: + *

+ * 1.自定义图片加载逻辑和框架 + * 2.自定义选择器所有ui样式 + * 3.自定义提示 + * 4.自定义超出最大选择数量的提示 + * 5.自定义媒体库扫描和剪裁的加载框loading + * 6.自定义选择器完成按钮点击事件的拦截 + * 7.拦截选择器取消操作,用于弹出二次确认框 + * 8.图片点击事件拦截,如果返回true,则不会执行选中操纵,如果要拦截此事件并且要执行选中 + * 9.拍照点击事件拦截 + *

+ */ +public interface IPickerPresenter extends Serializable { + /** + * 图片加载,在安卓10上,外部存储的图片路径只能用Uri加载,私有目录的图片可以用绝对路径加载 + * 所以这个方法务必需要区分有uri和无uri的情况 + * 一般媒体库直接扫描出来的图片是含有uri的,而剪裁生成的图片保存在私有目录中,因此没有uri,只有绝对路径 + * 所以这里需要做一个兼容处理,例如如下代码: + * + *

+ * if (item.getUri() != null) { + * Glide.with(view.getContext()).load(item.getUri().into((ImageView) view); + * } else { + * Glide.with(view.getContext()).load(item.path).into((ImageView) view); + * } + *

+ * + * @param view imageView + * @param item 图片信息 + * @param size 加载尺寸 + * @param isThumbnail 是否是缩略图 + */ + void displayImage(View view, ImageItem item, int size, boolean isThumbnail); + + /** + * 设置自定义ui显示样式 + * 该方法返回一个PickerUiConfig对象 + * + *

+ * 该对象可以配置如下信息: + * 1.主题色 + * 2.相关页面背景色 + * 3.选择器标题栏,底部栏,item,文件夹列表item,预览页面,剪裁页面的定制 + *

+ *

+ * 详细使用方法参考 (@link https://github.com/yangpeixing/YImagePicker/blob/master/YPX_ImagePicker_androidx/app/src/main/java/com/ypx/imagepickerdemo/style/WeChatPresenter.java) + * + * @param context 上下文 + * @return PickerUiConfig + */ + @NonNull + PickerUiConfig getUiConfig(@Nullable Context context); + + /** + * 提示 + * + * @param context 上下文 + * @param msg 提示文本 + */ + void tip(@Nullable Context context, String msg); + + /** + * 选择超过数量限制提示 + * + * @param context 上下文 + * @param maxCount 最大数量 + */ + void overMaxCountTip(@Nullable Context context, int maxCount); + + /** + * 显示loading加载框,注意需要调用show方法 + * + * @param activity 启动加载框的activity + * @param progressSceneEnum {@link ProgressSceneEnum} + * + *

+ * 当progressSceneEnum==当ProgressSceneEnum.loadMediaItem 时,代表在加载媒体文件时显示加载框 + * 目前框架内规定,当文件夹内媒体文件少于1000时,强制不显示加载框,大于1000时才会执行此方法 + *

+ *

+ * 当progressSceneEnum==当ProgressSceneEnum.crop 时,代表是剪裁页面的加载框 + *

+ * @return DialogInterface 对象,用于关闭加载框,返回null代表不显示加载框 + */ + DialogInterface showProgressDialog(@Nullable Activity activity, ProgressSceneEnum progressSceneEnum); + + /** + * 拦截选择器完成按钮点击事件 + * + * @param activity 当前选择器activity + * @param selectedList 已选中的列表 + * @return true:则拦截选择器完成回调, false,执行默认的选择器回调 + */ + boolean interceptPickerCompleteClick(@Nullable Activity activity, ArrayList selectedList, BaseSelectConfig selectConfig); + + /** + * 拦截选择器取消操作,用于弹出二次确认框 + * + * @param activity 当前选择器页面 + * @param selectedList 当前已经选择的文件列表 + * @return true:则拦截选择器取消, false,不处理选择器取消操作 + */ + boolean interceptPickerCancel(@Nullable Activity activity, ArrayList selectedList); + + /** + *

+ * 图片点击事件拦截,如果返回true,则不会执行选中操纵,如果要拦截此事件并且要执行选中 + * 请调用如下代码: + *

+ * adapter.preformCheckItem() + *

+ *

+ * 此方法可以用来跳转到任意一个页面,比如自定义的预览 + * + * @param activity 上下文 + * @param imageItem 当前图片 + * @param selectImageList 当前选中列表 + * @param allSetImageList 当前文件夹所有图片 + * @param selectConfig 选择器配置项,如果是微信样式,则selectConfig继承自MultiSelectConfig + * 如果是小红书剪裁样式,则继承自CropSelectConfig + * @param adapter 当前列表适配器,用于刷新数据 + * @param isClickCheckBox 是否点击item右上角的选中框 + * @param reloadExecutor 刷新器 + * @return 是否拦截 + */ + boolean interceptItemClick(@Nullable Activity activity, ImageItem imageItem, ArrayList selectImageList, + ArrayList allSetImageList, BaseSelectConfig selectConfig, PickerItemAdapter adapter, + boolean isClickCheckBox, + @Nullable IReloadExecutor reloadExecutor); + + /** + * 拍照点击事件拦截 + * + * @param activity 当前activity + * @param takePhoto 拍照接口 + * @return 是否拦截 + */ + boolean interceptCameraClick(@Nullable Activity activity, ICameraExecutor takePhoto); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java new file mode 100644 index 0000000..9ba69ca --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java @@ -0,0 +1,240 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; + +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; + +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class MediaUtils { + + + public static String copyUriToLocalMedia(Context context, ImageItem imageItem, String path) { + if (imageItem == null) { + return null; + } + return copyUriToLocalMedia(context, imageItem.getUri(), path, imageItem.mimeType); + } + + /** + * 从相册中copy出文件并预处理 TODO 应该在异步线程中执行 + * + * @param context + * @param uri + * @param path 文件保存路径 + * @return + */ + public static String copyUriToLocalMedia(Context context, Uri uri, String path, String mimeType) { + if (uri == null) { + return null; + } + String filePath = ""; + try { + if (MimeType.isImage(mimeType)) { + StringBuilder timeStamp = new StringBuilder(); + timeStamp.append("aos"); + timeStamp.append(System.currentTimeMillis()); + for (int i = 0; i < 4; i++) { + timeStamp.append((int) (Math.random() * 10)); + } + String postfix = "jpg"; + if (MimeType.isGif(mimeType)) { + postfix = "gif"; + } + String filename = String.format("%s.%s", timeStamp, postfix); + filePath = path + "/" + filename; + File file = new File(filePath); + copyFileToInternalStorage(context, uri, file); + } else if (MimeType.isVideo(mimeType)) { + String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.getDefault()).format(new Date()); + String filename = String.format("VIDEO_%s.mp4", timeStamp); + filePath = path + "/" + filename; + File file = new File(filePath); + copyFileToInternalStorage(context, uri, file); + } else if (mimeType.startsWith("audio/mpeg")) { + String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.getDefault()).format(new Date()); + String filename = String.format("MP3_%s.mp3", timeStamp); + filePath = path + "/" + filename; + File file = new File(filePath); + copyFileToInternalStorage(context, uri, file); + } + } catch (Exception ignored) { + } + return filePath; + } + + /** + * 复制文件到内部存储空间 + */ + public static void copyFileToInternalStorage(Context context, Uri uri, File destinationFile) { + InputStream inputStream = null; + OutputStream outputStream = null; + + try { + inputStream = context.getContentResolver().openInputStream(uri); + outputStream = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ? Files.newOutputStream(destinationFile.toPath()) : new FileOutputStream(destinationFile); + + byte[] buffer = new byte[4096]; + int bytesRead; + while (inputStream !=null && (bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + // 关闭流 + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (outputStream != null) { + try { + outputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + public static void copyPublicDirToPackageDir(Context context, Uri uri, File file) { + InputStream is = null; + OutputStream os = null; + try { + ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r"); + is = new FileInputStream(pfd.getFileDescriptor()); + os = new FileOutputStream(file); + byte[] buf = new byte[2048]; + int len = 0; + while ((len = is.read(buf)) != -1) { // 循环从输入流读取 buffer字节 + os.write(buf, 0, len); // 将读取的输入流写入到输出流 + } + os.flush(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + closeIO(os, is); + } + } + + /** + * 关闭IO + * + * @param closeables closeable + */ + public static void closeIO(Closeable... closeables) { + if (closeables == null) { + return; + } + try { + for (Closeable closeable : closeables) { + if (closeable != null) { + closeable.close(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static String getExternalFilePath(Context context, String fileDir) { + String dirPath = context.getExternalFilesDir(fileDir).getAbsolutePath(); + File file = new File(dirPath); + if (!file.exists()) { + file.mkdirs(); + } + return dirPath; + } + + /** + * 删除目录 + * + * @param dirFile 目录 + * @return {@code true}: 删除成功
{@code false}: 删除失败 + */ + public static boolean deleteDir(File dirFile) { + if (dirFile == null) { + return false; + } + // 目录不存在返回true + if (!dirFile.exists()) { + return true; + } + // 不是目录返回false + if (!dirFile.isDirectory()) { + return false; + } + // 现在文件存在且是文件夹 + File[] files = dirFile.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isFile()) { + if (!deleteFile(file)) { + return false; + } + } else if (file.isDirectory()) { + if (!deleteDir(file)) { + return false; + } + } + } + } + return dirFile.delete(); + } + + /** + * 删除文件 + * + * @param file 文件 + * @return {@code true}: 删除成功
{@code false}: 删除失败 + */ + public static boolean deleteFile(File file) { + return file != null && (!file.exists() || file.isFile() && file.delete()); + } + + /** + * 获取一个临时文件存储地址(保存的文件会在app下次启动时删除) + * + * @return filePath + */ + public static String getTempFilePath() { + String filePath; + Context context = CommonApplicationProxy.INSTANCE.getApplication(); + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || !Environment.isExternalStorageRemovable()) { + //外部存储可用 + filePath = getExternalFilePath(context, "temp"); + } else { + //外部存储不可用 + filePath = context.getDir("temp", Context.MODE_PRIVATE).getAbsolutePath(); + File file = new File(filePath); + if (!file.exists()) { + file.mkdirs(); + } + } + return filePath; + } + + public static void cleanTempFile(Context context) { + deleteDir(context.getExternalFilesDir("temp")); + deleteDir(context.getDir("temp", Context.MODE_PRIVATE)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java new file mode 100644 index 0000000..3df17b3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java @@ -0,0 +1,467 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.view.View; + +import androidx.exifinterface.media.ExifInterface; + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.UriPathInfo; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.FileNameMap; +import java.net.URLConnection; + +/** + * Time: 2019/7/17 14:16 + * Author:ypx + * Description:文件工具类 + */ +public class PBitmapUtils { + /** + * 根据相对路径获取图片宽高 + * + * @param c 上下文 + * @param uri 图片uri地址 + * @return 宽高信息 + */ + + public static int[] getImageWidthHeight(Context c, Uri uri) { + try { + ParcelFileDescriptor parcelFileDescriptor = c.getContentResolver() + .openFileDescriptor(uri, "r"); + if (parcelFileDescriptor != null) { + FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor); + parcelFileDescriptor.close(); + return new int[]{image.getWidth(), image.getHeight()}; + } + } catch (Exception e) { + e.printStackTrace(); + } + + return new int[]{0, 0}; + } + + public static Bitmap getBitmapFromUri(Context c, Uri uri) { + try { + ParcelFileDescriptor parcelFileDescriptor = c.getContentResolver() + .openFileDescriptor(uri, "r"); + if (parcelFileDescriptor != null) { + FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor); + parcelFileDescriptor.close(); + return image; + } + } catch (Exception e) { + e.printStackTrace(); + } + + return null; + } + + + /** + * 根据绝对路径得到图片的宽高,亲测比楼上速度快几十倍 + * + * @param imageLocalPath 绝对路径!绝对路径!绝对路径! + * @return 宽高 + */ + public static int[] getImageWidthHeight(String imageLocalPath) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(imageLocalPath, options); + int width = options.outWidth; + int height = options.outHeight; + + int orientation = getImageOrientation(imageLocalPath); + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + case ExifInterface.ORIENTATION_ROTATE_270: { + return new int[]{height, width}; + } + default: { + return new int[]{width, height}; + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return new int[]{0, 0}; + } + + + public static int getImageOrientation(String imageLocalPath) { + try { + ExifInterface exifInterface = new ExifInterface(imageLocalPath); + int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + return orientation; + } catch (IOException e) { + e.printStackTrace(); + return ExifInterface.ORIENTATION_NORMAL; + } + } + + /** + * @param context 上下文 + * @return 获取app私有目录 + */ + public static File getPickerFileDirectory(Context context) { + File file = new File(context.getExternalFilesDir(null), ImagePicker.DEFAULT_FILE_NAME); + if (!file.exists()) { + if (file.mkdirs()) { + return file; + } + } + return file; + } + + /** + * 获取系统相册文件路径 + */ + public static File getDCIMDirectory() { + File dcim = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); + if (!dcim.exists()) { + dcim = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + } + return dcim; + } + + + /** + * androidQ中默认项目私有文件只能存储在Android/data/包名/files/下 + * + * @param context 上下文 + * @param bitmap 要保存的bitmap + * @param fileName 图片名称 + * @param compressFormat 图片格式 + * @return 该图片的绝对路径,不是Uri相对路径 + */ + public static String saveBitmapToFile(Context context, + Bitmap bitmap, + String fileName, + Bitmap.CompressFormat compressFormat) { + + File file = getPickerFileDirectory(context); + file = new File(file, fileName + "." + compressFormat.toString().toLowerCase()); + try { + FileOutputStream b = new FileOutputStream(file); + bitmap.compress(compressFormat, 90, b);// 把数据写入文件 + b.flush(); + b.close(); + return file.getAbsolutePath(); + } catch (Exception e) { + e.printStackTrace(); + if (file.exists()) { + file.delete(); + } + return "Exception:" + e.getMessage(); + } + } + + public static Uri saveBitmapToDCIM(Context context, + Bitmap bitmap, + String fileName, + Bitmap.CompressFormat compressFormat) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/" + compressFormat.toString()); + contentValues.put(MediaStore.Files.FileColumns.WIDTH, bitmap.getWidth()); + contentValues.put(MediaStore.Files.FileColumns.HEIGHT, bitmap.getHeight()); + String suffix = "." + compressFormat.toString().toLowerCase(); + String path = getDCIMDirectory().getAbsolutePath() + File.separator + fileName + suffix; +// try { +// contentValues.put(MediaStore.Images.Media.DATA, path); +// } catch (Exception ignored) { +// +// } + //执行insert操作,向系统文件夹中添加文件 + //EXTERNAL_CONTENT_URI代表外部存储器,该值不变 + Uri uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues); + if (uri != null) { + //若生成了uri,则表示该文件添加成功 + //使用流将内容写入该uri中即可 + try { + OutputStream outputStream = context.getContentResolver().openOutputStream(uri); + if (outputStream != null) { + bitmap.compress(compressFormat, 90, outputStream); + outputStream.flush(); + outputStream.close(); + return uri; + } + } catch (Exception e) { + e.printStackTrace(); + return uri; + } + } + + return uri; + } + + /** + * Uri路径转绝对路径 + * + * @param context 上下文,用于提供getContentResolver + * @param uri 要查询的uri + * @return 绝对路径 + */ + public static String getPathFromUri(Context context, Uri uri) { + String path = ""; + String DATA = Build.VERSION.SDK_INT < 29 ? + MediaStore.Images.ImageColumns.DATA + : MediaStore.Images.ImageColumns.RELATIVE_PATH; + Cursor cursor = context.getContentResolver().query(uri, new String[]{DATA}, + null, null, null); + if (null != cursor) { + if (cursor.moveToFirst()) { + int index = cursor.getColumnIndex(DATA); + if (index > -1) + path = cursor.getString(index); + } + cursor.close(); + } + return path; + } + + /** + * androidQ方式保存一张bitmap到DCIM根目录下 + * + * @param context 当前context + * @param sourceFilePath 当前要生成的bitmap + * @param fileName 图片名称 + * @param mimeType 图片格式 + * @return 此图片的Uri + */ + public static UriPathInfo copyFileToDCIM(Context context, String sourceFilePath, + String fileName, MimeType mimeType) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.Images.Media.MIME_TYPE, mimeType.toString()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentValues.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()); + } + boolean isImage = MimeType.isImage(mimeType.toString()); + if (isImage) { + int[] size = getImageWidthHeight(sourceFilePath); + contentValues.put(MediaStore.Files.FileColumns.WIDTH, size[0]); + contentValues.put(MediaStore.Files.FileColumns.HEIGHT, size[1]); + } else { + long duration = PBitmapUtils.getLocalVideoDuration(sourceFilePath); + contentValues.put("duration", duration); + } + String suffix = "." + mimeType.getSuffix(); + String path = getDCIMDirectory().getAbsolutePath() + File.separator + fileName + suffix; +// try { +// contentValues.put(MediaStore.Images.Media.DATA, path); +// } catch (Exception ignored) { +// +// } + //执行insert操作,向系统文件夹中添加文件 + //EXTERNAL_CONTENT_URI代表外部存储器,该值不变 + Uri uri = context.getContentResolver().insert(isImage ? MediaStore.Images.Media.EXTERNAL_CONTENT_URI : + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues); + copyFile(context, sourceFilePath, uri); + return new UriPathInfo(uri, path); + } + + private static boolean copyFile(Context context, String sourceFilePath, final Uri insertUri) { + if (insertUri == null) { + return false; + } + ContentResolver resolver = context.getContentResolver(); + InputStream is = null;//输入流 + OutputStream os = null;//输出流 + try { + os = resolver.openOutputStream(insertUri); + if (os == null) { + return false; + } + File sourceFile = new File(sourceFilePath); + if (sourceFile.exists()) { // 文件存在时 + is = new FileInputStream(sourceFile); // 读入原文件 + //输入流读取文件,输出流写入指定目录 + return copyFileWithStream(os, is); + } + return false; + } catch (Exception e) { + e.printStackTrace(); + return false; + } finally { + try { + if (is != null) { + is.close(); + } + if (os != null) { + os.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + private static boolean copyFileWithStream(OutputStream os, InputStream is) { + if (os == null || is == null) { + return false; + } + int read = 0; + while (true) { + try { + byte[] buffer = new byte[1444]; + while ((read = is.read(buffer)) != -1) { + os.write(buffer, 0, read); + os.flush(); + } + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } finally { + try { + os.close(); + is.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + /** + * @return view的截图,在InVisible时也可以获取到bitmap + */ + public static Bitmap getViewBitmap(View view) { + view.measure(View.MeasureSpec.makeMeasureSpec(view.getMeasuredWidth(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(view.getMeasuredHeight(), View.MeasureSpec.EXACTLY)); + view.setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_HIGH); + view.setDrawingCacheEnabled(true); + view.buildDrawingCache(true); + return view.getDrawingCache(true); + } + + + /** + * 获取视频封面 + */ + public static Bitmap getVideoThumb(String path) { + MediaMetadataRetriever media = new MediaMetadataRetriever(); + media.setDataSource(path); + return media.getFrameAtTime(); + } + + /** + * 获取视频时长 + */ + public static long getLocalVideoDuration(String videoPath) { + int duration; + try { + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + mmr.setDataSource(videoPath); + duration = Integer.parseInt(mmr.extractMetadata + (MediaMetadataRetriever.METADATA_KEY_DURATION)); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + return duration; + } + + /** + * 刷新相册 + */ + public static void refreshGalleryAddPic(Context context, Uri uri) { + if (context == null) { + return; + } + Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + mediaScanIntent.setData(uri); + context.sendBroadcast(mediaScanIntent); + } + + public static String getMimeTypeFromUri(Activity context, Uri uri) { + ContentResolver resolver = context.getContentResolver(); + return resolver.getType(uri); + } + + public static String getMimeTypeFromPath(String path) { + FileNameMap fileNameMap = URLConnection.getFileNameMap(); + return fileNameMap.getContentTypeFor(new File(path).getName()); + } + + + public static Uri getImageContentUri(Context context, String path) { + Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{MediaStore.Images.Media._ID}, MediaStore.Images.Media.DATA + "=? ", + new String[]{path}, null); + if (cursor != null && cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); + if (columnIndex != -1) { + int id = cursor.getInt(columnIndex); + Uri baseUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + cursor.close(); + return Uri.withAppendedPath(baseUri, "" + id); + } else { + return null; + } + } else { + return null; + } + } + + public static Uri getVideoContentUri(Context context, String path) { + Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{MediaStore.Images.Media._ID}, MediaStore.Images.Media.DATA + "=? ", + new String[]{path}, null); + if (cursor != null && cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); + if (columnIndex != -1) { + int id = cursor.getInt(columnIndex); + Uri baseUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + cursor.close(); + return Uri.withAppendedPath(baseUri, "" + id); + } else { + return null; + } + } else { + return null; + } + } + + public static Uri getContentUri(String mimeType, long id) { + if (id <= 0) { + return null; + } + Uri contentUri; + if (MimeType.isImage(mimeType)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if (MimeType.isVideo(mimeType)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else { + contentUri = MediaStore.Files.getContentUri("external"); + } + return ContentUris.withAppendedId(contentUri, id); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java new file mode 100644 index 0000000..e756575 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java @@ -0,0 +1,39 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; + +/** + * Utils to get corner drawable + */ +public class PCornerUtils { + public static Drawable cornerDrawable(final int bgColor, float cornerradius) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(cornerradius); + bg.setColor(bgColor); + return bg; + } + + public static Drawable cornerDrawableAndStroke(final int bgColor, float cornerradius, int strokeWidth, int strokeColor) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(cornerradius); + bg.setColor(bgColor); + bg.setStroke(strokeWidth, strokeColor); + return bg; + } + + public static Drawable cornerDrawable(float cornerradius) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(cornerradius); + return bg; + } + + public static Drawable cornerDrawable(final int bgColor, float[] cornerradius) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadii(cornerradius); + bg.setColor(bgColor); + + return bg; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java new file mode 100644 index 0000000..161fc04 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java @@ -0,0 +1,153 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.annotation.SuppressLint; +import android.content.Context; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.utils.TimeUtils; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; + + +/** + * 时间工具类 + */ +@SuppressLint("SimpleDateFormat") +public class PDateUtil { + + public static String getStrTime(Context context, long cc_time) { + if (cc_time == 0) { + return ""; + } + if (String.valueOf(cc_time).length() <= 10) { + cc_time = cc_time * 1000L; + } + Date date = new Date(cc_time); + if (isToday(date)) { + return context.getString(R.string.picker_str_today); + } + if (isThisWeek(date)) { + return context.getString(R.string.picker_str_this_week); + } + if (isThisMonth(date)) { + return context.getString(R.string.picker_str_this_months); + } + return new SimpleDateFormat(context.getString(R.string.picker_str_time_format)).format(date); + } + + //判断选择的日期是否是本周 + private static boolean isThisWeek(Date date) { + Calendar calendar = Calendar.getInstance(); + int currentWeek = calendar.get(Calendar.WEEK_OF_YEAR); + calendar.setTime(date); + int paramWeek = calendar.get(Calendar.WEEK_OF_YEAR); + return paramWeek == currentWeek; + } + + //判断选择的日期是否是今天 + private static boolean isToday(Date date) { + return isThisTime(date, TimeUtils.YMD_PATTERN); + } + + //判断选择的日期是否是本月 + private static boolean isThisMonth(Date date) { + return isThisTime(date, TimeUtils.YM_PATTERN); + } + + private static boolean isThisTime(Date date, String pattern) { + SimpleDateFormat sdf = new SimpleDateFormat(pattern); + String param = sdf.format(date);//参数时间 + String now = sdf.format(new Date());//当前时间 + return param.equals(now); + } + + + /** + * 获取视频时长(格式化) + */ + public static String getVideoDuration(long timestamp) { + if (timestamp < 1000) { + return "00:01"; + } + Date date = new Date(timestamp); + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss"); + return simpleDateFormat.format(date); + } + + + /* + * 毫秒转化 + */ + public static String formatTime(Context context, Long ms) { + Integer ss = 1000; + Integer mi = ss * 60; + Integer hh = mi * 60; + Integer dd = hh * 24; + +// Long day = ms / dd; +// Long hour = (ms - day * dd) / hh; +// Long minute = (ms - day * dd - hour * hh) / mi; +// Long second = (ms - day * dd - hour * hh - minute * mi) / ss; +// long milliSecond = ms - day * dd - hour * hh - minute * mi - second * ss; +// +// StringBuilder sb = new StringBuilder(); +// if (day > 0) { +// sb.append(day).append(context.getString(R.string.picker_str_day)); +// } +// if (hour > 0) { +// sb.append(hour).append(context.getString(R.string.picker_str_hour)); +// } +// if (minute > 0) { +// sb.append(minute).append(context.getString(R.string.picker_str_minute)); +// } +// if (second > 0) { +// sb.append(second).append(context.getString(R.string.picker_str_second)); +// } +// if (milliSecond > 0) { +// sb.append(milliSecond).append(context.getString(R.string.picker_str_milli)); +// } + + Long day = ms / dd; + Long hour = (ms - day * dd) / hh; + Long minute = (ms - day * dd - hour * hh) / mi; + Long second = (ms - day * dd - hour * hh - minute * mi) / ss; + + StringBuilder sb = new StringBuilder(); + if (day > 0) { + sb.append(day).append(context.getString(R.string.picker_str_unit)); + } + if (hour > 0) { + if (hour > 9) { + sb.append(hour).append(context.getString(R.string.picker_str_unit)); + } else { + sb.append("0").append(hour).append(context.getString(R.string.picker_str_unit)); + } + } + if (minute > 0) { + if (minute > 9) { + sb.append(minute).append(context.getString(R.string.picker_str_unit)); + } else { + sb.append("0").append(minute).append(context.getString(R.string.picker_str_unit)); + } + } + if (second >= 0) { + if (minute == 0) { + if (second > 9) { + sb.append("00:").append(second); + } else { + sb.append("00:0").append(second); + } + } else { + if (second > 9) { + sb.append(second); + } else { + sb.append("0").append(second); + } + } + } + + return sb.toString(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java new file mode 100644 index 0000000..0ef2d35 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java @@ -0,0 +1,162 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.Manifest; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import com.remax.visualnovel.BuildConfig; +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.hjq.permissions.permission.PermissionNames; + + +/** + * Description: 权限工具类 + *

+ * Author: peixing.yang + * Date: 2019/3/1 + */ +public class PPermissionUtils { + private Context context; + + public static boolean hasCameraPermissions(Activity activity) { + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions(new String[]{Manifest.permission.CAMERA}, ImagePicker.REQ_CAMERA); + return false; + } + return true; + } + + public static boolean hasStoragePermissions(Activity activity) { + String permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? PermissionNames.READ_MEDIA_IMAGES : PermissionNames.WRITE_EXTERNAL_STORAGE; + if (ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions(new String[]{permission}, ImagePicker.REQ_STORAGE); + return false; + } + return true; + } + + public static PPermissionUtils create(Context context) { + return new PPermissionUtils(context); + } + + public PPermissionUtils(Context context) { + this.context = context; + } + + public void showSetPermissionDialog(final String msg) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setMessage(msg); + builder.setCancelable(false); + builder.setNegativeButton(context.getString(R.string.picker_str_permission_refuse_setting), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + if (msg.equals(context.getString(R.string.picker_str_storage_permission))) { + ((Activity) context).finish(); + } + } + }); + builder.setPositiveButton(context.getString(R.string.picker_str_permission_go_setting), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + gotoPermissionSet(); + } + }); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + public void gotoPermissionSet() { + String brand = Build.BRAND;//手机厂商 + if (TextUtils.equals(brand.toLowerCase(), "redmi") || TextUtils.equals(brand.toLowerCase(), "xiaomi")) { + gotoMiuiPermission();//小米 + } else if (TextUtils.equals(brand.toLowerCase(), "meizu")) { + gotoMeizuPermission(); + } else if (TextUtils.equals(brand.toLowerCase(), "huawei") || TextUtils.equals(brand.toLowerCase(), "honor")) { + gotoHuaweiPermission(); + } else { + context.startActivity(getAppDetailSettingIntent()); + } + } + + /** + * 跳转到miui的权限管理页面 + */ + private void gotoMiuiPermission() { + try { // MIUI 8 + Intent localIntent = new Intent("miui.intent.action.APP_PERM_EDITOR"); + localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.PermissionsEditorActivity"); + localIntent.putExtra("extra_pkgname", context.getPackageName()); + context.startActivity(localIntent); + } catch (Exception e) { + try { // MIUI 5/6/7 + Intent localIntent = new Intent("miui.intent.action.APP_PERM_EDITOR"); + localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.AppPermissionsEditorActivity"); + localIntent.putExtra("extra_pkgname", context.getPackageName()); + context.startActivity(localIntent); + } catch (Exception e1) { // 否则跳转到应用详情 + context.startActivity(getAppDetailSettingIntent()); + } + } + } + + /** + * 跳转到魅族的权限管理系统 + */ + private void gotoMeizuPermission() { + try { + Intent intent = new Intent("com.meizu.safe.security.SHOW_APPSEC"); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.putExtra("packageName", BuildConfig.APPLICATION_ID); + context.startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + context.startActivity(getAppDetailSettingIntent()); + } + } + + /** + * 华为的权限管理页面 + */ + private void gotoHuaweiPermission() { + try { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ComponentName comp = new ComponentName("com.huawei.systemmanager", "com.huawei.permissionmanager.ui.MainActivity");//华为权限管理 + intent.setComponent(comp); + context.startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + context.startActivity(getAppDetailSettingIntent()); + } + + } + + /** + * 获取应用详情页面intent(如果找不到要跳转的界面,也可以先把用户引导到系统设置页面) + * + * @return + */ + private Intent getAppDetailSettingIntent() { + Intent localIntent = new Intent(); + localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + localIntent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS"); + localIntent.setData(Uri.fromParts("package", context.getPackageName(), null)); + return localIntent; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java new file mode 100644 index 0000000..8242f67 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java @@ -0,0 +1,43 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.content.Context; +import android.media.MediaScannerConnection; +import android.net.Uri; + +/** + * @author yangpeixing + * 媒体扫描刷新类 + */ +public class PSingleMediaScanner implements MediaScannerConnection.MediaScannerConnectionClient { + private MediaScannerConnection mediaScannerConnection; + private String mPath; + private ScanListener mListener; + + public interface ScanListener { + void onScanFinish(); + } + + public PSingleMediaScanner(Context context, String mPath, ScanListener mListener) { + this.mPath = mPath; + this.mListener = mListener; + this.mediaScannerConnection = new MediaScannerConnection(context, this); + this.mediaScannerConnection.connect(); + } + + @Override + public void onMediaScannerConnected() { + mediaScannerConnection.scanFile(mPath, null); + } + + @Override + public void onScanCompleted(String mPath, Uri mUri) { + mediaScannerConnection.disconnect(); + if (mListener != null) { + mListener.onScanFinish(); + } + } + + public static void refresh(Context context, String path, ScanListener scanListener) { + new PSingleMediaScanner(context.getApplicationContext(), path, scanListener); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java new file mode 100644 index 0000000..f3a7ef8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java @@ -0,0 +1,174 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.app.Activity; +import android.graphics.Color; +import android.os.Build; +import android.view.DisplayCutout; +import android.view.View; +import android.view.WindowManager; + +import java.lang.reflect.Method; + +/** + * 状态栏工具类 + */ +public class PStatusBarUtil { + + /** + * 是否有刘海屏 + */ + public static boolean hasNotchInScreen(Activity activity) { + // android P 以上有标准 API 来判断是否有刘海屏 + if (Build.VERSION.SDK_INT >= 28) { + try { + DisplayCutout displayCutout = activity.getWindow().getDecorView().getRootWindowInsets().getDisplayCutout(); + if (displayCutout != null) { + // 说明有刘海屏 + return true; + } + } catch (Exception e) { + return false; + } + } else { + // 通过其他方式判断是否有刘海屏 目前官方提供有开发文档的就 小米,vivo,华为(荣耀),oppo + String manufacturer = Build.MANUFACTURER; + if (manufacturer == null || manufacturer.length() == 0) { + return false; + } else if (manufacturer.equalsIgnoreCase("HUAWEI")) { + return hasNotchHw(activity); + } else if (manufacturer.equalsIgnoreCase("xiaomi")) { + return hasNotchXiaoMi(activity); + } else if (manufacturer.equalsIgnoreCase("oppo")) { + return hasNotchOPPO(activity); + } else if (manufacturer.equalsIgnoreCase("vivo")) { + return hasNotchVIVO(activity); + } else { + return false; + } + } + return false; + } + + /** + * 判断vivo是否有刘海屏 + * https://swsdl.vivo.com.cn/appstore/developer/uploadfile/20180328/20180328152252602.pdf + * + * @param activity + * @return + */ + public static boolean hasNotchVIVO(Activity activity) { + try { + Class c = Class.forName("android.util.FtFeature"); + Method get = c.getMethod("isFeatureSupport", int.class); + return (boolean) (get.invoke(c, 0x20)); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 判断oppo是否有刘海屏 + * https://open.oppomobile.com/wiki/doc#id=10159 + * + * @param activity + * @return + */ + public static boolean hasNotchOPPO(Activity activity) { + try { + return activity.getPackageManager().hasSystemFeature("com.oppo.feature.screen.heteromorphism"); + } catch (Exception e) { + e.printStackTrace(); + } + return false; + } + + /** + * 判断xiaomi是否有刘海屏 + * https://dev.mi.com/console/doc/detail?pId=1293 + * + * @param activity + * @return + */ + public static boolean hasNotchXiaoMi(Activity activity) { + try { + Class c = Class.forName("android.os.SystemProperties"); + Method get = c.getMethod("getInt", String.class, int.class); + return (int) (get.invoke(c, "ro.miui.notch", 1)) == 1; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 判断华为是否有刘海屏 + * https://devcenter-test.huawei.com/consumer/cn/devservice/doc/50114 + */ + public static boolean hasNotchHw(Activity activity) { + try { + ClassLoader cl = activity.getClassLoader(); + Class HwNotchSizeUtil = cl.loadClass("com.huawei.android.util.HwNotchSizeUtil"); + Method get = HwNotchSizeUtil.getMethod("hasNotchInScreen"); + return (boolean) get.invoke(HwNotchSizeUtil); + } catch (Exception e) { + return false; + } + } + + public static void setStatusBar(Activity activity, int bgColor, boolean isFullScreen, boolean isDarkStatusBarIcon) { + //5.0以下不处理 + if (Build.VERSION.SDK_INT < 21) { + return; + } + int option = 0; + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + //只有在6.0以上才改变状态栏颜色,否则在5.0机器上,电量条图标是白色的,标题栏也是白色的,就看不见电量条了了 + //在5.0上显示默认灰色背景色 + if (Build.VERSION.SDK_INT >= 23) { + // 设置状态栏底色颜色 + activity.getWindow().setStatusBarColor(bgColor); + //浅色状态栏,则让状态栏图标变黑,深色状态栏,则让状态栏图标变白 + if (isDarkStatusBarIcon) { + if (isFullScreen) { + option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + } else { + if (isFullScreen) { + option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + } else { + option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + } + } + } else { + if (isFullScreen) { + activity.getWindow().setStatusBarColor(Color.TRANSPARENT); + option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + } else { + activity.getWindow().setStatusBarColor(bgColor); + option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + } + } + activity.getWindow().getDecorView().setSystemUiVisibility(option); + } + + + /** + * 显示标题背景颜色 + */ + public static boolean isDarkColor(int colorInt) { + int gray = (int) (Color.red(colorInt) * 0.299 + Color.green(colorInt) * 0.587 + Color.blue(colorInt) * 0.114); + return gray >= 192; + } + + public static void fullScreen(Activity activity) { + activity.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java new file mode 100644 index 0000000..a359471 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java @@ -0,0 +1,254 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.content.Context; +import android.graphics.Color; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; + +import java.lang.ref.WeakReference; + +/** + * Description: View尺寸相关工具类 + *

+ * Author: peixing.yang + * Date: 2018/12/24-15:40 + */ +final public class PViewSizeUtils { + public static void setViewSize(View view, int width, int height) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params == null) { + params = new ViewGroup.LayoutParams(width, height); + } else { + if (width != -1) { + params.width = width; + } + if (height != -1) { + params.height = height; + } + } + viewWeakReference.get().setLayoutParams(params); + } + } + + + public static void setViewSize(View view, int width, float widthHeightRatio) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params == null) { + params = new ViewGroup.LayoutParams(width, (int) (width / widthHeightRatio)); + } else { + if (width != -1) { + params.width = width; + } + if (widthHeightRatio != 0) { + params.height = (int) (width / widthHeightRatio); + } + } + viewWeakReference.get().setLayoutParams(params); + } + } + + public static void setViewSize(View view, int width, int height, int marginLeft, int marginTop, int marginRight, int marginBottom) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + if (viewWeakReference.get().getLayoutParams() != null && + (viewWeakReference.get().getLayoutParams() instanceof ViewGroup.MarginLayoutParams)) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + params.width = width; + params.height = height; + if (marginLeft != -1) { + params.leftMargin = marginLeft; + } + if (marginRight != -1) { + params.rightMargin = marginRight; + } + if (marginTop != -1) { + params.topMargin = marginTop; + } + if (marginBottom != -1) { + params.bottomMargin = marginBottom; + } + viewWeakReference.get().setLayoutParams(params); + } + } + } + + + public static void setViewMargin(View view, int margin) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + if (viewWeakReference.get().getLayoutParams() != null && + (viewWeakReference.get().getLayoutParams() instanceof ViewGroup.MarginLayoutParams)) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (margin != -1) { + params.leftMargin = margin; + params.rightMargin = margin; + params.topMargin = margin; + params.bottomMargin = margin; + } + viewWeakReference.get().setLayoutParams(params); + } + } + } + + /** + * 获取View的高度 + * + * @param v view + * @return 高度 + */ + public static int getViewHeight(View v) { + ViewGroup.LayoutParams params = v.getLayoutParams(); + if (params != null) { + return params.height; + } + return v.getHeight(); + } + + /** + * 获取View的宽度 + * + * @param v view + * @return 宽度 + */ + public static int getViewWidth(View v) { + ViewGroup.LayoutParams params = v.getLayoutParams(); + if (params != null) { + return params.width; + } + return v.getWidth(); + } + + public static void setMarginStart(View view, int marginStart) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.leftMargin = marginStart; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static void setMarginStartAndEnd(View view, int marginStart, int marginEnd) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.leftMargin = marginStart; + params.rightMargin = marginEnd; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static void setMarginTopAndBottom(View view, int marginTop, int marginBottom) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.topMargin = marginTop; + params.bottomMargin = marginBottom; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static void setMarginTop(View view, int marginTop) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.topMargin = marginTop; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static int getMarginTop(View view) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + return params.topMargin; + } + } + return 0; + } + + public static int dp(Context context, float dp) { + if (context == null) { + return 0; + } + float density = context.getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + public static int sp(Context context, int spValue) { + final float fontScale = context.getResources().getDisplayMetrics().scaledDensity; + return (int) (spValue * fontScale + 0.5f); + } + + /** + * 获得屏幕宽度 + */ + public static int getScreenWidth(Context context) { + if (context == null) { + return 0; + } + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.widthPixels; + } + + /** + * 获得屏幕高度 + */ + public static int getScreenHeight(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.heightPixels; + } + + /** + * 两个颜色渐变转化 + * + * @param color1 默认色 + * @param color2 目标色 + * @param ratio 渐变率(0~1) + * @return 计算后的颜色 + */ + public static int blendColors(int color1, int color2, float ratio) { + final float inverseRation = 1f - ratio; + float r = (Color.red(color1) * ratio) + + (Color.red(color2) * inverseRation); + float g = (Color.green(color1) * ratio) + + (Color.green(color2) * inverseRation); + float b = (Color.blue(color1) * ratio) + + (Color.blue(color2) * inverseRation); + return Color.rgb((int) r, (int) g, (int) b); + } + + + private static long lastTime = 0L; + + public static boolean onDoubleClick() { + boolean flag = false; + long time = System.currentTimeMillis() - lastTime; + + if (time > 300) { + flag = true; + } + lastTime = System.currentTimeMillis(); + return !flag; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java new file mode 100644 index 0000000..7ed02a1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java @@ -0,0 +1,30 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.app.Activity; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + +import java.io.File; + +/** + * Time: 2019/7/24 15:43 + * Author:ypx + * Description: + */ +public class PickerFileProvider extends FileProvider { + + public static Uri getUriForFile(@NonNull Activity context, + @NonNull File file) { + Uri uri; + if (android.os.Build.VERSION.SDK_INT < 24) { + uri = Uri.fromFile(file); + } else { + uri = getUriForFile(context, context.getApplication().getPackageName() + + ".picker.fileprovider", file); + } + + return uri; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java new file mode 100644 index 0000000..7c143b6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java @@ -0,0 +1,199 @@ +package com.remax.visualnovel.widget.imagepicker.views; + +import android.graphics.Color; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; + + +/** + * Time: 2019/11/13 15:54 + * Author:ypx + * Description:选择器ui样式配置类 + */ +public class PickerUiConfig { + + /** + * 文件夹列表从上往下弹入 + */ + public static final int DIRECTION_TOP = 1; + + /** + * 文件夹列表从底部往上弹入 + */ + public static final int DIRECTION_BOTTOM = 2; + + //全局相关属性 + private int pickerBackgroundColor = Color.BLACK; + private int previewBackgroundColor = Color.BLACK; + private int singleCropBackgroundColor = Color.BLACK; + private int folderListOpenDirection = DIRECTION_TOP; + private int folderListOpenMaxMargin = 0; + private boolean isShowStatusBar; + private int statusBarColor; + private int videoPauseIconID; + + //小红书剪裁相关属性 + private int cropViewBackgroundColor = Color.BLACK; + private int fullIconID; + private int fitIconID; + private int gapIconID; + private int FillIconID; + + //选择器ui提供类 + private PickerUiProvider pickerUiProvider; + + /** + * 主题色 + */ + private int themeColor; + + public PickerUiProvider getPickerUiProvider() { + if (pickerUiProvider == null) { + return new PickerUiProvider(); + } + return pickerUiProvider; + } + + public void setPickerUiProvider(PickerUiProvider pickerUiProvider) { + this.pickerUiProvider = pickerUiProvider; + } + + public int getPickerBackgroundColor() { + if (pickerBackgroundColor == 0) { + return Color.WHITE; + } + return pickerBackgroundColor; + } + + public int getSingleCropBackgroundColor() { + return singleCropBackgroundColor; + } + + public void setSingleCropBackgroundColor(int singleCropBackgroundColor) { + this.singleCropBackgroundColor = singleCropBackgroundColor; + } + + public void setPickerBackgroundColor(int pickerBackgroundColor) { + this.pickerBackgroundColor = pickerBackgroundColor; + } + + public int getPreviewBackgroundColor() { + return previewBackgroundColor; + } + + public void setPreviewBackgroundColor(int previewBackgroundColor) { + this.previewBackgroundColor = previewBackgroundColor; + } + + public int getFolderListOpenDirection() { + return folderListOpenDirection; + } + + public void setFolderListOpenDirection(int folderListOpenDirection) { + this.folderListOpenDirection = folderListOpenDirection; + } + + public boolean isShowFromBottom() { + return folderListOpenDirection == DIRECTION_BOTTOM; + } + + public boolean isShowStatusBar() { + return isShowStatusBar; + } + + public void setShowStatusBar(boolean showStatusBar) { + isShowStatusBar = showStatusBar; + } + + public int getStatusBarColor() { + return statusBarColor; + } + + public void setStatusBarColor(int statusBarColor) { + this.statusBarColor = statusBarColor; + } + + public int getFolderListOpenMaxMargin() { + return folderListOpenMaxMargin; + } + + public void setFolderListOpenMaxMargin(int folderListOpenMaxHeight) { + this.folderListOpenMaxMargin = folderListOpenMaxHeight; + } + + public int getCropViewBackgroundColor() { + if (cropViewBackgroundColor == 0) { + return Color.BLACK; + } + return cropViewBackgroundColor; + } + + public void setCropViewBackgroundColor(int cropViewBackgroundColor) { + this.cropViewBackgroundColor = cropViewBackgroundColor; + } + + public int getFullIconID() { + if (fullIconID == 0) { + fullIconID = R.mipmap.picker_icon_full; + } + return fullIconID; + } + + public void setFullIconID(int fullIconID) { + this.fullIconID = fullIconID; + } + + public int getFitIconID() { + if (fitIconID == 0) { + fitIconID = R.mipmap.picker_icon_fit; + } + return fitIconID; + } + + public void setFitIconID(int fitIconID) { + this.fitIconID = fitIconID; + } + + public int getGapIconID() { + if (gapIconID == 0) { + gapIconID = R.mipmap.picker_icon_haswhite; + } + return gapIconID; + } + + public void setGapIconID(int gapIconID) { + this.gapIconID = gapIconID; + } + + public int getFillIconID() { + if (FillIconID == 0) { + FillIconID = R.mipmap.picker_icon_fill; + } + return FillIconID; + } + + public void setFillIconID(int fillIconID) { + FillIconID = fillIconID; + } + + public int getVideoPauseIconID() { + if (videoPauseIconID == 0) { + videoPauseIconID = R.mipmap.picker_icon_video; + } + return videoPauseIconID; + } + + public void setVideoPauseIconID(int videoPauseIconID) { + this.videoPauseIconID = videoPauseIconID; + } + + public int getThemeColor() { + return themeColor; + } + + public void setThemeColor(int themeColor) { + this.themeColor = themeColor; + ImagePicker.setThemeColor(themeColor); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java new file mode 100644 index 0000000..7ffa592 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java @@ -0,0 +1,77 @@ +package com.remax.visualnovel.widget.imagepicker.views; + +import android.content.Context; + +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerFolderItemView; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerItemView; +import com.remax.visualnovel.widget.imagepicker.views.base.SingleCropControllerView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.BottomBar; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.FolderItemView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.ItemView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.PreviewControllerView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.TitleBar; + + +/** + * Time: 2019/10/27 22:22 + * Author:ypx + * Description: 选择器UI提供类,默认为微信样式 + */ +public class PickerUiProvider { + + /** + * 获取标题栏 + * + * @param context 调用此view的activity + */ + public PickerControllerView getTitleBar(Context context) { + return new TitleBar(context); + } + + /** + * 获取底部栏 + * + * @param context 调用此view的activity + * @return {@link PickerControllerView}对象,参考{ WXBottomBar} + */ + public PickerControllerView getBottomBar(Context context) { + return new BottomBar(context, false); + } + + /** + * 获取自定义item + * + * @param context 调用此view的activity + */ + public PickerItemView getItemView(Context context) { + return new ItemView(context); + } + + /** + * 获取自定义文件夹item + * + * @param context 调用此view的activity + */ + public PickerFolderItemView getFolderItemView(Context context) { + return new FolderItemView(context); + } + + /** + * 获取自定义预览界面 + * + * @param context 调用此view的activity + */ + public PreviewControllerView getPreviewControllerView(Context context) { + return new PreviewControllerView(context); + } + + /** + * 获取自定义单图剪裁界面 + * + * @param context 调用此view的activity + */ + public SingleCropControllerView getSingleCropControllerView(Context context) { + return new com.remax.visualnovel.widget.imagepicker.views.wrapper.SingleCropControllerView(context); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java new file mode 100644 index 0000000..bd93a10 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java @@ -0,0 +1,68 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.app.Activity; +import android.content.Context; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + + + +/** + * Time: 2019/11/11 14:33 + * Author:ypx + * Description:所有View的基类,其中包含了dp、getScreenWidth + */ +public abstract class PBaseLayout extends LinearLayout { + protected View view; + + /** + * @return item布局id + */ + protected abstract int getLayoutId(); + + /** + * @param view 初始化view + */ + protected abstract void initView(View view); + + public PBaseLayout(Context context) { + super(context); + init(); + } + + public PBaseLayout(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public PBaseLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + view = LayoutInflater.from(getContext()).inflate(getLayoutId(), this, true); + initView(view); + } + + protected int getScreenWidth() { + WindowManager wm = (WindowManager) getContext() + .getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.widthPixels; + } + + public void onBackPressed() { + if (getContext() instanceof Activity) { + ((Activity) getContext()).onBackPressed(); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java new file mode 100644 index 0000000..32145d3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java @@ -0,0 +1,86 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; + +import java.util.ArrayList; + +/** + * Time: 2019/11/7 13:24 + * Author:ypx + * Description: 选择器控制类 + */ +public abstract class PickerControllerView extends PBaseLayout { + + /** + * @return 获取当前view的高度 + */ + public abstract int getViewHeight(); + + /** + * @return 获取可以点击触发完成回调的View,如果返回null,则代表不可以触发完成回调 + */ + public abstract View getCanClickToCompleteView(); + + /** + * @return 获取可以跳转到预览的View,如果返回null,则代表不可触发跳转预览 + */ + public abstract View getCanClickToIntentPreviewView(); + + /** + * @return 获取可以切换文件夹列表的View,返回null,则不切换文件夹 + */ + public abstract View getCanClickToToggleFolderListView(); + + /** + * @param title 设置默认标题 + */ + public abstract void setTitle(String title); + + /** + * 切换文件夹 + * + * @param isOpen 当前是否是打开文件夹 + */ + public abstract void onTransitImageSet(boolean isOpen); + + /** + * 切换文件夹回调 + * + * @param imageSet 当前切换的文件夹 + */ + public abstract void onImageSetSelected(ImageSet imageSet); + + /** + * 刷新完成按钮状态 + * + * @param selectedList 已选中列表 + * @param selectConfig 选择器配置项 + */ + public abstract void refreshCompleteViewState(ArrayList selectedList, BaseSelectConfig selectConfig); + + public boolean isAddInParent() { + return getViewHeight() > 0; + } + + public PickerControllerView(Context context) { + super(context); + } + + public PickerControllerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PickerControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java new file mode 100644 index 0000000..5a54625 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java @@ -0,0 +1,51 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + + +/** + * Time: 2019/11/13 14:39 + * Author:ypx + * Description:自定义文件夹item + */ +public abstract class PickerFolderItemView extends PBaseLayout { + + /** + * @return 获取每个item的高度,如果自适应返回-1 + */ + public abstract int getItemHeight(); + + /** + * 加载文件夹缩略图 + * + * @param imageSet 文件夹 + * @param presenter presenter + */ + public abstract void displayCoverImage(ImageSet imageSet, IPickerPresenter presenter); + + /** + * 加载item + * + * @param imageSet 当前文件夹信息 + */ + public abstract void loadItem(ImageSet imageSet); + + public PickerFolderItemView(Context context) { + super(context); + } + + public PickerFolderItemView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PickerFolderItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java new file mode 100644 index 0000000..cef0cca --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java @@ -0,0 +1,103 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + + +/** + * Time: 2019/8/8 15:42 + * Author:ypx + * Description:自定义item基类 + *

+ * 执行流程: + * initItem——> enableItem ——> disableItem + *

+ */ +public abstract class PickerItemView extends PBaseLayout { + /** + * 获取拍照item样式 + * + * @param selectConfig 选择配置类 + * @param presenter implements of {@link IPickerPresenter} + * @return 拍照 + */ + public abstract View getCameraView(BaseSelectConfig selectConfig, IPickerPresenter presenter); + + /** + * @return 返回用于点击选中item的view + */ + public abstract View getCheckBoxView(); + + /** + * 初始化item + * + * @param imageItem 当前图片 + * @param presenter presenter + * @param selectConfig 选择器配置项 + */ + public abstract void initItem(ImageItem imageItem, IPickerPresenter presenter, BaseSelectConfig selectConfig); + + /** + * 当检测到此item不能被选中时,执行此方法 + * + * @param imageItem 当前图片 + * @param disableCode 不能选中的原因 {@link PickerItemDisableCode} + */ + public abstract void disableItem(ImageItem imageItem, int disableCode); + + /** + * 在disableItem之前调用,用于正常加载每个item + * + * @param imageItem 当前图片 + * @param isChecked 是否已经被选中 + * @param indexOfSelectedList 在已选中列表里的索引 + */ + public abstract void enableItem(ImageItem imageItem, boolean isChecked, int indexOfSelectedList, boolean isMax); + + private RecyclerView.Adapter adapter; + private int position; + + public void setAdapter(RecyclerView.Adapter adapter) { + this.adapter = adapter; + } + + public void setPosition(int position) { + this.position = position; + } + + public void notifyDataSetChanged() { + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + + public RecyclerView.Adapter getAdapter() { + return adapter; + } + + public int getPosition() { + return position; + } + + public PickerItemView(Context context) { + super(context); + } + + public PickerItemView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PickerItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java new file mode 100644 index 0000000..bd27d2b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java @@ -0,0 +1,149 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.RelativeLayout; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.helper.DetailImageLoadHelper; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PickerFileProvider; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + +import java.io.File; +import java.util.ArrayList; + +/** + * Time: 2019/11/13 14:39 + * Author:ypx + * Description:自定义预览页面 + */ +public abstract class PreviewControllerView extends PBaseLayout { + + /** + * 设置状态栏 + */ + public abstract void setStatusBar(); + + /** + * 初始化数据 + * + * @param selectConfig 选择配置项 + * @param presenter presenter + * @param uiConfig ui配置类 + * @param selectedList 已选中列表 + */ + public abstract void initData(BaseSelectConfig selectConfig, IPickerPresenter presenter, + PickerUiConfig uiConfig, ArrayList selectedList); + + /** + * @return 获取可以点击完成的View + */ + public abstract View getCompleteView(); + + /** + * 单击图片 + */ + public abstract void singleTap(); + + /** + * 图片切换回调 + * + * @param position 当前图片索引 + * @param imageItem 当前图片信息 + * @param totalPreviewCount 总预览数 + */ + public abstract void onPageSelected(int position, ImageItem imageItem, int totalPreviewCount); + + + public PreviewControllerView(Context context) { + super(context); + } + + public PreviewControllerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PreviewControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * 获取预览的fragment里的布局 + * + * @param fragment 当前加载的fragment,可以使用以下方式来绑定生命周期 + *

+ * fragment.getLifecycle().addObserver(new ILifeCycleCallBack() { + * public void onResume() {} + * public void onPause() {} + * public void onDestroy() {} + * }); + *

+ * @param imageItem 当前加载imageitem + * @param presenter presenter + * @return 预览的布局 + */ + public View getItemView(Fragment fragment, final ImageItem imageItem, IPickerPresenter presenter) { + if (imageItem == null) { + return new View(fragment.getContext()); + } + + RelativeLayout layout = new RelativeLayout(getContext()); + final CropImageView imageView = new CropImageView(getContext()); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + // 启用图片缩放功能 + imageView.setBounceEnable(true); + imageView.enable(); + imageView.setShowImageRectLine(false); + imageView.setCanShowTouchLine(false); + imageView.setMaxScale(3.0f); + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + imageView.setLayoutParams(params); + layout.setLayoutParams(params); + layout.addView(imageView); + + ImageView mVideoImg = new ImageView(getContext()); + mVideoImg.setImageDrawable(getResources().getDrawable(R.mipmap.picker_icon_video)); + RelativeLayout.LayoutParams params1 = new RelativeLayout.LayoutParams(PViewSizeUtils.dp(getContext(), 80), PViewSizeUtils.dp(getContext(), 80)); + mVideoImg.setLayoutParams(params1); + params1.addRule(RelativeLayout.CENTER_IN_PARENT); + layout.addView(mVideoImg, params1); + + if (imageItem.isVideo()) { + mVideoImg.setVisibility(View.VISIBLE); + } else { + mVideoImg.setVisibility(View.GONE); + } + + imageView.setOnClickListener(v -> { + if (imageItem.isVideo()) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + Uri uri = imageItem.getUri(); + if (uri == null) { + uri = PickerFileProvider.getUriForFile((Activity) getContext(), new File(imageItem.path)); + } + intent.setDataAndType(uri, "video/*"); + getContext().startActivity(intent); + return; + } + singleTap(); + }); + DetailImageLoadHelper.displayDetailImage(false, imageView, presenter, imageItem); + return layout; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java new file mode 100644 index 0000000..a8b6a36 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java @@ -0,0 +1,46 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + + +/** + * Time: 2019/11/13 14:39 + * Author:ypx + * Description:自定义剪裁页面 + */ +public abstract class SingleCropControllerView extends PBaseLayout { + + /** + * 设置状态栏 + */ + public abstract void setStatusBar(); + + /** + * @return 获取可以点击完成的View + */ + public abstract View getCompleteView(); + + /** + * @param cropImageView 剪裁的ImageView + * @param params params + */ + public abstract void setCropViewParams(CropImageView cropImageView, MarginLayoutParams params); + + public SingleCropControllerView(Context context) { + super(context); + } + + public SingleCropControllerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public SingleCropControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt new file mode 100644 index 0000000..0d7976c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt @@ -0,0 +1,80 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickBottomBarBinding +import com.remax.visualnovel.extension.findActivityContext +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.dialoglib.ScreenUtils +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView + +/** + * Created by HJW on 2022/10/24 + */ +class BottomBar constructor(context: Context?, private val isAlbum: Boolean) : PickerControllerView(context) { + + private var binding: PickBottomBarBinding? = null + + private var tempSelectedList: ArrayList? = null + + override fun getLayoutId(): Int { + return R.layout.pick_bottom_bar + } + + override fun initView(view: View?) { + view?.run { + binding = PickBottomBarBinding.bind(this.findViewById(R.id.bottomBar)) + binding?.also { + it.content.maxWidth = ScreenUtils.getScreenWidth() - 106.dp + setOnClick(it.checkBox, it.tips) { + val appCompatActivity = context?.findActivityContext() as? AppCompatActivity + when (this) { + it.checkBox -> { + + } + + it.tips -> { + + } + } + } + } + } + } + + override fun getViewHeight(): Int { + return if (isAlbum) 68.dp else 0 + } + + override fun getCanClickToCompleteView(): View? { + return null + } + + override fun getCanClickToIntentPreviewView(): View? { + return null + } + + override fun getCanClickToToggleFolderListView(): View? { + return null + } + + override fun setTitle(title: String?) {} + + override fun onTransitImageSet(isOpen: Boolean) {} + + override fun onImageSetSelected(imageSet: ImageSet?) {} + + override fun refreshCompleteViewState(selectedList: ArrayList?, selectConfig: BaseSelectConfig?) { + tempSelectedList = selectedList + tempSelectedList?.forEach { + it.openLock = binding?.checkBox?.isChecked == true + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt new file mode 100644 index 0000000..14ab399 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt @@ -0,0 +1,55 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerFolderItemBinding +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter +import com.remax.visualnovel.widget.imagepicker.views.base.PickerFolderItemView + +/** + * Created by HJW on 2022/10/21 + */ +class FolderItemView constructor(context: Context?) : PickerFolderItemView(context) { + + private var binding: PickerFolderItemBinding? = null + + override fun getLayoutId(): Int { + return R.layout.picker_folder_item + } + + override fun initView(view: View?) { + view?.let { + binding = PickerFolderItemBinding.bind(this.findViewById(R.id.group)) + } + } + + override fun getItemHeight(): Int = -1 + + override fun displayCoverImage(imageSet: ImageSet?, presenter: IPickerPresenter?) { + imageSet?.also { + if (it.cover != null) { + presenter?.displayImage(binding?.cover, it.cover, binding?.cover?.measuredWidth ?: 0, true) + } else { + val imageItem = ImageItem() + imageItem.path = it.coverPath + imageItem.setUriPath(it.coverPath) + presenter?.displayImage(binding?.cover, imageItem, binding?.cover?.measuredWidth ?: 0, true) + } + } + } + + override fun loadItem(imageSet: ImageSet?) { + binding?.run { + imageSet?.let { + name.text = it.name + size.text = it.count.toString() + indicator.isVisible = it.isSelected + } + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt new file mode 100644 index 0000000..3542a8a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt @@ -0,0 +1,100 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerImageGridItemBinding +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter +import com.remax.visualnovel.widget.imagepicker.views.base.PickerItemView +import com.remax.visualnovel.widget.imagepicker.widget.ShowTypeImageView + +/** + * Created by HJW on 2022/10/21 + */ +class ItemView constructor(context: Context?) : PickerItemView(context) { + + private var selectConfig: BaseSelectConfig? = null + + private var binding: PickerImageGridItemBinding? = null + + override fun getLayoutId(): Int { + return R.layout.picker_image_grid_item + } + + override fun initView(view: View?) { + view?.run { + binding = PickerImageGridItemBinding.bind(this.findViewById(R.id.group)) + } + } + + override fun getCameraView(selectConfig: BaseSelectConfig?, presenter: IPickerPresenter?): View { + return LayoutInflater.from(context).inflate(R.layout.picker_item_camera, null, false) + } + + override fun getCheckBoxView(): View? { + return binding?.mCheckBoxPanel + } + + override fun initItem(imageItem: ImageItem?, presenter: IPickerPresenter?, selectConfig: BaseSelectConfig?) { + this.selectConfig = selectConfig + presenter?.displayImage(binding?.mImageView, imageItem, binding?.mImageView?.width ?: 0, true) + } + + override fun disableItem(imageItem: ImageItem?, disableCode: Int) { + //默认开启校验是否超过最大数,当超过最大选择数量时, + if (disableCode == PickerItemDisableCode.DISABLE_OVER_MAX_COUNT) { + return + } + binding?.mCheckBox?.isVisible = false + binding?.vMasker?.isVisible = true + } + + @SuppressLint("SetTextI18n") + override fun enableItem(imageItem: ImageItem?, isChecked: Boolean, indexOfSelectedList: Int, isMax: Boolean) { + binding?.run { + /*imageItem?.let { + if (it.isVideo) { + PickerImageGridItemBinding.mVideoLayout.isVisible = true + PickerImageGridItemBinding.mVideoTime.text = imageItem.getDurationFormat() + PickerImageGridItemBinding.mImageView.setType(ShowTypeImageView.TYPE_NONE) + } else { + PickerImageGridItemBinding.mVideoLayout.isVisible = false + PickerImageGridItemBinding.mImageView.setTypeFromImage(imageItem) + } + + + PickerImageGridItemBinding.mCheckBox.isVisible = true + PickerImageGridItemBinding.mCheckBoxPanel.isVisible = true + + val isVideoSinglePickAndAutoComplete = imageItem.isVideo && selectConfig!!.isVideoSinglePickAndAutoComplete + if (isVideoSinglePickAndAutoComplete || selectConfig?.isSinglePickAutoComplete == true && (selectConfig?.maxCount ?: 0) <= 1) { + PickerImageGridItemBinding.mCheckBox.isVisible = false + PickerImageGridItemBinding.mCheckBoxPanel.isVisible = false + } + + PickerImageGridItemBinding.mCheckBox.viewChecked(isChecked) + PickerImageGridItemBinding.mCheckBox.text = if (isChecked) "${indexOfSelectedList + 1}" else null + + PickerImageGridItemBinding.singleCheckBox.isVisible = false + + + + if (isMax) { + PickerImageGridItemBinding.singleCheckBox.isVisible = indexOfSelectedList == 0 + PickerImageGridItemBinding.vMasker.isVisible = !isChecked + } else { + PickerImageGridItemBinding.vMasker.isVisible = false + } + }*/ + + } + + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt new file mode 100644 index 0000000..6740415 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt @@ -0,0 +1,185 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import android.view.animation.AnimationUtils +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerWxPreviewBottombarBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.itemdecoration.HorizontalItemDecoration +import com.remax.visualnovel.widget.imagepicker.adapter.MultiPreviewAdapter +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper.SimpleItemTouchHelperCallback +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig +import com.remax.visualnovel.widget.imagepicker.views.base.PreviewControllerView + +import java.io.File +import java.lang.ref.WeakReference + +/** + * Created by HJW on 2022/10/21 + */ +class PreviewControllerView constructor(context: Context?) : PreviewControllerView(context) { + + private var binding: PickerWxPreviewBottombarBinding? = null + + private var previewAdapter: MultiPreviewAdapter? = null + private var presenter: IPickerPresenter? = null + private var selectConfig: BaseSelectConfig? = null + private var uiConfig: PickerUiConfig? = null + private var selectedList: ArrayList? = null + private var isShowBottomBar = true + private var isShowCompleteBtn = true + + override fun getLayoutId(): Int = R.layout.picker_wx_preview_bottombar + + override fun initView(view: View?) { + view?.run { + binding = PickerWxPreviewBottombarBinding.bind(this.findViewById(R.id.group)) + binding?.let { + setOnClick(it.mTitleContainer.navBack) { + onBackPressed() + } + } + } + } + + override fun setStatusBar() { + + } + + override fun initData(selectConfig: BaseSelectConfig?, presenter: IPickerPresenter?, uiConfig: PickerUiConfig?, selectedList: ArrayList?) { + this.selectConfig = selectConfig + this.presenter = presenter + this.selectedList = selectedList + this.uiConfig = uiConfig + initUI() + initPreviewList() + binding?.run { + bottomBar.isVisible = isShowBottomBar + mPreviewRecyclerView.isVisible = isShowBottomBar + + mTitleContainer.rightConfirmBtn.isVisible = isShowCompleteBtn + } + } + + private var currentImageItem: ImageItem? = null + private var currentPosition = 0 + private var currentIsChecked = false + + private fun initUI() { + binding?.run { + setOnClick(mSelectCheckBox) { + currentIsChecked = !currentIsChecked + checkButton.viewChecked(currentIsChecked) + if (currentIsChecked) { + if (!File(currentImageItem?.path.toString()).exists()) { + checkButton.viewChecked(false) + context.toast(context.getString(R.string.file_not_found_hint)) + } else { + val disableCode = PickerItemDisableCode.getItemDisableCode( + currentImageItem, selectConfig, selectedList, + selectedList?.contains(currentImageItem) == true + ) + + if (disableCode != PickerItemDisableCode.NORMAL) { + val message = PickerItemDisableCode.getMessageFormCode(context, disableCode, presenter, selectConfig) + if (message.isNotEmpty()) { + presenter?.tip(WeakReference(context).get(), message) + } + checkButton.viewChecked(false) + return@setOnClick + } + if (selectedList?.contains(currentImageItem) == false) { + currentImageItem?.let { selectedList?.add(it) } + } + } + } else { + selectedList?.remove(currentImageItem) + } + notifyPreviewList(currentImageItem) + mTitleContainer.rightConfirmBtn.isEnabled = !selectedList.isNullOrEmpty() + } + } + } + + private fun initPreviewList() { + binding?.run { + mPreviewRecyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + val space = 12.dp + mPreviewRecyclerView.addItemDecoration(HorizontalItemDecoration(space, space * 2, space * 2)) + previewAdapter = MultiPreviewAdapter(selectedList, presenter) + mPreviewRecyclerView.adapter = previewAdapter + val callback = SimpleItemTouchHelperCallback(previewAdapter) + val mItemTouchHelper = ItemTouchHelper(callback) + mItemTouchHelper.attachToRecyclerView(mPreviewRecyclerView) + } + } + + override fun getCompleteView(): View? { + return binding?.mTitleContainer?.rightConfirmBtn + } + + override fun singleTap() { + binding?.run { + if (mTitleContainer.navRoot.isVisible) { + mTitleContainer.navRoot.animation = AnimationUtils.loadAnimation(context, R.anim.picker_top_out) + mTitleContainer.navRoot.isVisible = false + if (isShowBottomBar) { + bottomBar.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_out) + bottomBar.isVisible = false + mPreviewRecyclerView.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_out) + mPreviewRecyclerView.isVisible = false + } + } else { + mTitleContainer.navRoot.animation = AnimationUtils.loadAnimation(context, R.anim.picker_top_in) + mTitleContainer.navRoot.isVisible = true + if (isShowBottomBar) { + bottomBar.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_in) + bottomBar.isVisible = true + mPreviewRecyclerView.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_in) + mPreviewRecyclerView.isVisible = true + } + } + } + } + + /** + * 刷新预览编辑列表 + * + * @param imageItem 当前预览的图片 + */ + private fun notifyPreviewList(imageItem: ImageItem?) { + previewAdapter?.setPreviewImageItem(imageItem) + if (selectedList?.contains(imageItem) == true) { + binding?.mPreviewRecyclerView?.smoothScrollToPosition(selectedList?.indexOf(imageItem) ?: 0) + } + } + + override fun onPageSelected(position: Int, imageItem: ImageItem?, totalPreviewCount: Int) { + binding?.run { + currentPosition = position + currentImageItem = imageItem + mTitleContainer.tvTitle.text = String.format("%d/%d", position + 1, totalPreviewCount) + currentIsChecked = selectedList?.contains(imageItem) == true + checkButton.viewChecked(currentIsChecked) + notifyPreviewList(imageItem) + + if ((selectConfig?.maxCount ?: 0) <= 1 && selectConfig?.isSinglePickAutoComplete == true) { + mTitleContainer.rightConfirmBtn.isVisible = false + } else { + mTitleContainer.rightConfirmBtn.isVisible = true + mTitleContainer.rightConfirmBtn.isEnabled = !selectedList.isNullOrEmpty() + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt new file mode 100644 index 0000000..0d68984 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt @@ -0,0 +1,50 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.LayoutEpalCropBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView +import com.remax.visualnovel.widget.imagepicker.views.base.SingleCropControllerView + +/** + * Created by HJW on 2022/10/24 + */ +class SingleCropControllerView(context: Context?) : SingleCropControllerView(context) { + + private var binding: LayoutEpalCropBinding? = null + private var cropImageView: CropImageView? = null + + override fun getLayoutId(): Int { + return R.layout.layout_epal_crop + } + + override fun initView(view: View?) { + view?.run { + binding = LayoutEpalCropBinding.bind(this.findViewById(R.id.group)) + binding?.also { + setOnClick(it.cropBackLayout) { + when (this) { + it.cropBackLayout -> { + onBackPressed() + } + } + + } + } + } + } + + override fun setStatusBar() { + + } + + override fun getCompleteView(): View? { + return binding?.cropSaveLayout + } + + override fun setCropViewParams(cropImageView: CropImageView?, params: MarginLayoutParams?) { + this.cropImageView = cropImageView + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt new file mode 100644 index 0000000..7dcbedc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt @@ -0,0 +1,94 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerRedbookTitlebarBinding +import com.remax.visualnovel.extension.navRotationOpen +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.handleUIToken + +/** + * Created by HJW on 2022/10/24 + */ +class TitleBar constructor(context: Context?) : PickerControllerView(context) { + + private var binding: PickerRedbookTitlebarBinding? = null + + override fun getLayoutId(): Int { + return R.layout.picker_redbook_titlebar + } + + override fun initView(view: View?) { + view?.run { + binding = PickerRedbookTitlebarBinding.bind(this.findViewById(R.id.titleBar)) + binding?.also { + val bgColor = context.handleUIToken(R.string.color_background_specialmap)?.color ?: 0 + it.mTitleContainer.navBg.setBackgroundColor(bgColor) + it.mTitleContainer.navBack.setText(R.string.icon_close) + it.mTitleContainer.tvTitleArrow.isVisible = true + setOnClick(it.mTitleContainer.navBack) { + onBackPressed() + } + } + } + } + + override fun getViewHeight(): Int { + return context.resources.getDimensionPixelSize(R.dimen.nav_height) + } + + override fun getCanClickToCompleteView(): View? { + return binding?.mTitleContainer?.rightConfirmBtn + } + + override fun getCanClickToIntentPreviewView(): View? { + return null + } + + override fun getCanClickToToggleFolderListView(): View? { + return binding?.mTitleContainer?.tvTitleLayout + } + + override fun setTitle(title: String?) { + binding?.mTitleContainer?.also { + it.tvTitle.text = title + } + } + + override fun onTransitImageSet(isOpen: Boolean) { + binding?.run { + mTitleContainer.tvTitleArrow.navRotationOpen(isOpen) + mTitleContainer.tvTitle.changeTextColor { + textUIColorToken = context.getString(if (isOpen) R.string.color_primary_variant_normal else R.string.color_txt_primary_normal) + } + mTitleContainer.tvTitleArrow.changeTextColor { + textUIColorToken = context.getString(if (isOpen) R.string.color_primary_variant_normal else R.string.color_txt_secondary_normal) + } + } + } + + override fun onImageSetSelected(imageSet: ImageSet?) { + binding?.mTitleContainer?.also { + it.tvTitle.text = imageSet?.name + } + } + + override fun refreshCompleteViewState(selectedList: ArrayList?, selectConfig: BaseSelectConfig?) { + binding?.also { + if ((selectConfig?.maxCount ?: 0) <= 1 && selectConfig?.isSinglePickAutoComplete == true) { + it.mTitleContainer.rightConfirmBtn.isVisible = false + } else { + it.mTitleContainer.rightConfirmBtn.isVisible = true + it.mTitleContainer.rightConfirmBtn.isEnabled = !selectedList.isNullOrEmpty() + } + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java new file mode 100644 index 0000000..5e98996 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java @@ -0,0 +1,184 @@ +package com.remax.visualnovel.widget.imagepicker.widget; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.ImageView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +/** + * 可以根据宽高和类型动态显示长图和gif图标签 + *

+ * yangpeixing on 2017/12/7 16:40 + */ +@SuppressLint("AppCompatCustomView") +public class ShowTypeImageView extends ImageView { + public static final int TYPE_GIF = 1;//gif图片 + public static final int TYPE_LONG = 2;//长图 + public static final int TYPE_NONE = 3;//正常图 + public static final int TYPE_VIDEO = 5;//视频 + public static final int TYPE_IMAGECOUNT = 4;//数量 + + protected int imageType = TYPE_NONE; + + private String imageCountTip = ""; + + private boolean isSelect = false; + + public ShowTypeImageView(Context context) { + super(context); + init(); + } + + public ShowTypeImageView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ShowTypeImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public void setType(int type) { + this.imageType = type; + invalidate(); + } + + public void setSelect(boolean isSelect, int selectColor) { + this.isSelect = isSelect; + mSelectPaint.setColor(selectColor); + invalidate(); + } + + private Paint mCirclePaint; + private Paint mMaskPaint; + private Paint mBitmapPaint; + private Paint mTextPaint; + private RectF rectF; + private Paint mSelectPaint; + private Bitmap videoBitmap; + + private void init() { + mCirclePaint = new Paint(); + mCirclePaint.setAntiAlias(true); + mCirclePaint.setColor(Color.parseColor("#ffffff")); + mCirclePaint.setAlpha(200); + + mMaskPaint = new Paint(); + mMaskPaint.setAntiAlias(true); + mMaskPaint.setColor(Color.parseColor("#40000000")); + + mBitmapPaint = new Paint(); + mBitmapPaint.setAntiAlias(true); + + mTextPaint = new Paint(); + mTextPaint.setAntiAlias(true); + mTextPaint.setColor(Color.parseColor("#90000000")); + mTextPaint.setTextSize(sp(12)); + mTextPaint.setTypeface(Typeface.DEFAULT_BOLD); + rectF = new RectF(); + + mSelectPaint = new Paint(); + mSelectPaint.setAntiAlias(true); + mSelectPaint.setStrokeWidth(dp(4)); + mSelectPaint.setStyle(Paint.Style.STROKE); + + try { + videoBitmap = ((BitmapDrawable) getResources().getDrawable(R.mipmap.picker_item_video)).getBitmap(); + } catch (Exception ignored) { + + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (isSelect) { + canvas.drawRect(0, 0, getWidth(), getHeight(), mSelectPaint); + } + + if (imageType == TYPE_NONE) { + return; + } + int width = getWidth(); + int height = getHeight(); + + switch (imageType) { + case TYPE_VIDEO: + if (videoBitmap != null) { + canvas.drawRect(0, 0, width, height, mMaskPaint); + canvas.drawBitmap(videoBitmap, (width - videoBitmap.getWidth()) >> 1, + (height - videoBitmap.getHeight()) >> 1, mBitmapPaint); + } + + break; + case TYPE_GIF: + canvas.drawCircle(width >> 1, height >> 1, width * 0.18f, mCirclePaint); + canvas.drawText("GIF", (width >> 1) - dp(10), (height >> 1) + dp(5), mTextPaint); + break; + + case TYPE_LONG: + rectF.left = width - dp(30); + rectF.top = height - dp(20); + rectF.right = width + dp(3); + rectF.bottom = height; + canvas.drawRoundRect(rectF, dp(3), dp(3), mCirclePaint); + canvas.drawText("长图", width - dp(27), height - dp(6), mTextPaint); + break; + + case TYPE_IMAGECOUNT: + rectF.left = width - dp(30); + rectF.top = height - dp(20); + rectF.right = width + dp(3); + rectF.bottom = height; + canvas.drawRoundRect(rectF, dp(3), dp(3), mCirclePaint); + canvas.drawText(imageCountTip, width - dp(27), height - dp(6), mTextPaint); + break; + } + + + } + + public int sp(float spValue) { + final float fontScale = getContext().getResources().getDisplayMetrics().scaledDensity; + return (int) (spValue * fontScale + 0.5f); + } + + public void setImageCountTip(String imageCountTip) { + this.imageCountTip = imageCountTip; + this.imageType = TYPE_IMAGECOUNT; + invalidate(); + } + + + public void setTypeFromImage(ImageItem imageItem) { + if (imageType == TYPE_IMAGECOUNT) { + return; + } + if (imageItem.isVideo()) { + setType(TYPE_VIDEO); + } else if (imageItem.isGif()) { + setType(TYPE_GIF); + } else if (imageItem.isLongImage()) { + setType(TYPE_LONG); + } else { + setType(TYPE_NONE); + } + } + + public int dp(float dpVal) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + dpVal, this.getResources().getDisplayMetrics()); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java new file mode 100644 index 0000000..abd3130 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java @@ -0,0 +1,106 @@ +package com.remax.visualnovel.widget.imagepicker.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Description: 可监听滑动的recyclerView + *

+ * Author: peixing.yang + * Date: 2019/2/22 + */ +public class TouchRecyclerView extends RecyclerView { + public TouchRecyclerView(@NonNull Context context) { + super(context); + } + + public TouchRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TouchRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + float firstY = 0; + float lastY = 0; + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + lastY = ev.getY(); + break; + case MotionEvent.ACTION_MOVE: + float y = ev.getY(); + + if (y < lastY) { + if (isTouchPointInView(touchView, ev.getX(), ev.getY())) { + if (dragScrollListener != null) { + int distance = (int) ((y - lastY)); + int defaultDis = (int) (lastY - getPaddingTop()); + dragScrollListener.onScrollOverTop(Math.abs(distance + defaultDis)); + } + } + } else { + if (dragScrollListener != null) { + dragScrollListener.onScrollDown((int) (y - lastY)); + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + lastY = 0; + if (dragScrollListener != null) { + dragScrollListener.onScrollUp(); + } + break; + } + return super.dispatchTouchEvent(ev); + } + + private View touchView; + + public void setTouchView(View view) { + this.touchView = view; + } + + //(x,y)是否在view的区域内 + private boolean isTouchPointInView(View view, float x, float y) { + if (view == null) { + return false; + } + int[] location = new int[2]; + view.getLocationOnScreen(location); + int left = location[0]; + int top = location[1]; + int right = left + view.getMeasuredWidth(); + int bottom = top + view.getMeasuredHeight(); + //view.isClickable() && + if (y >= top && y <= bottom && x >= left + && x <= right) { + return true; + } + return false; + } + + private onDragScrollListener dragScrollListener; + + public void setDragScrollListener(onDragScrollListener dragScrollListener) { + this.dragScrollListener = dragScrollListener; + } + + public interface onDragScrollListener { + void onScrollOverTop(int distance); + + void onScrollDown(int distance); + + void onScrollUp(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java new file mode 100644 index 0000000..cf76273 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java @@ -0,0 +1,1596 @@ +package com.remax.visualnovel.widget.imagepicker.widget.cropimage; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PointF; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.AnimationDrawable; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageView; +import android.widget.OverScroller; +import android.widget.Scroller; + +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; + + +/** + * Description: 剪裁ImageView + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +@SuppressLint("AppCompatCustomView") +public class CropImageView extends ImageView { + private final static int MIN_ROTATE = 35; + private final static int ANIM_DURING = 340; + private final static float MAX_SCALE = 2.5f; + private static int loadMaxSize = 0; + + private int mMinRotate; + private int mAnimDuring; + private float mMaxScale; + + private int MAX_FLING_OVER_SCROLL = 0; + private int MAX_OVER_RESISTANCE = 0; + + private Matrix mBaseMatrix = new Matrix(); + private Matrix mAnimMatrix = new Matrix(); + private Matrix mSynthesisMatrix = new Matrix(); + private Matrix mTmpMatrix = new Matrix(); + + private RotateGestureDetector mRotateDetector; + private GestureDetector mDetector; + private ScaleGestureDetector mScaleDetector; + private OnClickListener mClickListener; + + private ScaleType mScaleType = ScaleType.CENTER_INSIDE; + + private boolean hasMultiTouch; + private boolean hasDrawable; + private boolean isKnowSize; + private boolean hasOverTranslate; + private boolean isEnable = false; + private boolean isRotateEnable = false; + // 当前是否处于放大状态 + private boolean isZoomUp; + private boolean canRotate; + + private boolean imgLargeWidth; + private boolean imgLargeHeight; + + private float mRotateFlag; + private float mDegrees; + private float mScale = 1.0f; + private int mTranslateX; + private int mTranslateY; + + private RectF mCropRect = new RectF(); + private RectF mBaseRect = new RectF(); + private RectF mImgRect = new RectF(); + private RectF mTmpRect = new RectF(); + private RectF mCommonRect = new RectF(); + + private PointF mScreenCenter = new PointF(); + private PointF mScaleCenter = new PointF(); + private PointF mRotateCenter = new PointF(); + + private Paint linePaint; + + private Transform mTranslate = new Transform(); + + private RectF mClip; + private Runnable mCompleteCallBack; + + private OnLongClickListener mLongClick; + + private boolean isShowCropRect = true; + + public CropImageView(Context context) { + super(context); + init(); + } + + public CropImageView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CropImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + super.setScaleType(ScaleType.MATRIX); + if (mScaleType == null) mScaleType = ScaleType.CENTER_CROP; + mRotateDetector = new RotateGestureDetector(mRotateListener); + mDetector = new GestureDetector(getContext(), mGestureListener); + mScaleDetector = new ScaleGestureDetector(getContext(), mScaleListener); + float density = getResources().getDisplayMetrics().density; + MAX_FLING_OVER_SCROLL = (int) (density * 30); + MAX_OVER_RESISTANCE = (int) (density * 140); + + mMinRotate = MIN_ROTATE; + mAnimDuring = ANIM_DURING; + mMaxScale = MAX_SCALE; + + initCropLineRect(); + initCropRect(); + } + + @Override + public void setOnClickListener(OnClickListener l) { + super.setOnClickListener(l); + mClickListener = l; + } + + @Override + public void setScaleType(ScaleType scaleType) { + if (scaleType == ScaleType.MATRIX) return; + + if (scaleType != mScaleType) { + mScaleType = scaleType; + initBase(); + } + } + + public ScaleType getNewScaleType() { + return mScaleType; + } + + @Override + public void setOnLongClickListener(OnLongClickListener l) { + mLongClick = l; + } + + + /** + * 设置最大可以缩放的倍数 + */ + public void setMaxScale(float maxScale) { + mMaxScale = maxScale; + } + + /** + * 启用缩放功能 + */ + public void enable() { + isEnable = true; + } + + @Override + public void setImageResource(int resId) { + Drawable drawable = null; + try { + drawable = getResources().getDrawable(resId); + } catch (Exception ignored) { + } + + setImageDrawable(drawable); + } + + private Bitmap originalBitmap; + + public Bitmap getOriginalBitmap() { + return originalBitmap; + } + + @Override + public void setImageBitmap(Bitmap bm) { + if (bm == null || bm.getWidth() == 0 || bm.getHeight() == 0) { + return; + } + + originalBitmap = bm; + if (loadMaxSize == 0) { + loadMaxSize = Math.max(bm.getWidth(), bm.getHeight()); + } + + float ratio = bm.getWidth() * 1.00f / bm.getHeight() * 1.00f; + if (bm.getWidth() > loadMaxSize) { + bm = Bitmap.createScaledBitmap(bm, loadMaxSize, (int) (loadMaxSize / ratio), false); + } + + if (bm.getHeight() > loadMaxSize) { + bm = Bitmap.createScaledBitmap(bm, (int) (loadMaxSize * ratio), loadMaxSize, false); + } + + super.setImageBitmap(bm); + } + + @Override + public void setImageDrawable(Drawable drawable) { + super.setImageDrawable(drawable); + if (drawable == null) { + hasDrawable = false; + return; + } + if (!hasSize(drawable)) { + return; + } + + hasDrawable = true; + if (originalBitmap == null) { + if (drawable instanceof BitmapDrawable) { + originalBitmap = ((BitmapDrawable) drawable).getBitmap(); + } else if (drawable instanceof AnimationDrawable) { + AnimationDrawable drawable1 = (AnimationDrawable) drawable; + Drawable drawable2 = drawable1.getFrame(0); + if (drawable2 instanceof BitmapDrawable) { + originalBitmap = ((BitmapDrawable) drawable2).getBitmap(); + } + } else if (drawable instanceof GifDrawable) { + originalBitmap = ((GifDrawable) drawable).getFirstFrame(); + } + } + + if (onImageLoadListener != null) { + onImageLoadListener.onImageLoaded(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + onImageLoadListener = null; + } + + if (restoreInfo != null) { + mScaleType = restoreInfo.getScaleType(); + mCropRect = restoreInfo.mWidgetRect; + aspectX = (int) restoreInfo.mCropX; + aspectY = (int) restoreInfo.mCropY; + initBase(); + post(new Runnable() { + @Override + public void run() { + restoreCrop(); + } + }); + } else { + initBase(); + } + } + + private Info restoreInfo; + + public void setRestoreInfo(Info restoreInfo) { + this.restoreInfo = restoreInfo; + } + + /** + * 恢复状态 + */ + private void restoreCrop() { + Info info = restoreInfo; + + mTranslateX = 0; + mTranslateY = 0; + + if (info == null || info.mImgRect == null) { + return; + } + + float tcx = info.mImgRect.left + info.mImgRect.width() / 2; + float tcy = info.mImgRect.top + info.mImgRect.height() / 2; + + mScaleCenter.set(mImgRect.left + mImgRect.width() / 2, mImgRect.top + mImgRect.height() / 2); + mRotateCenter.set(mScaleCenter); + + // 将图片旋转回正常位置,用以计算 + mAnimMatrix.postRotate(-mDegrees, mScaleCenter.x, mScaleCenter.y); + mAnimMatrix.mapRect(mImgRect, mBaseRect); + + // 缩放 + float scaleX = info.mImgRect.width() / mBaseRect.width(); + float scaleY = info.mImgRect.height() / mBaseRect.height(); + float scale = scaleX > scaleY ? scaleX : scaleY; + + mAnimMatrix.postRotate(mDegrees, mScaleCenter.x, mScaleCenter.y); + mAnimMatrix.mapRect(mImgRect, mBaseRect); + + mDegrees = mDegrees % 360; + + mTranslate.withTranslate(0, 0, (int) (tcx - mScaleCenter.x), (int) (tcy - mScaleCenter.y)); + mTranslate.withScale(mScale, scale); + mTranslate.withRotate((int) mDegrees, (int) info.mDegrees, mAnimDuring * 2 / 3); + mTranslate.start(); + + restoreInfo = null; + } + + onImageLoadListener onImageLoadListener; + + public interface onImageLoadListener { + void onImageLoaded(float w, float h); + } + + public void setOnImageLoadListener(onImageLoadListener onImageLoadListener) { + this.onImageLoadListener = onImageLoadListener; + } + + private boolean hasSize(Drawable d) { + return (d.getIntrinsicHeight() > 0 && d.getIntrinsicWidth() > 0) + || (d.getMinimumWidth() > 0 && d.getMinimumHeight() > 0) + || (d.getBounds().width() > 0 && d.getBounds().height() > 0); + } + + private static int getDrawableWidth(Drawable d) { + int width = d.getIntrinsicWidth(); + if (width <= 0) width = d.getMinimumWidth(); + if (width <= 0) width = d.getBounds().width(); + return width; + } + + private static int getDrawableHeight(Drawable d) { + int height = d.getIntrinsicHeight(); + if (height <= 0) height = d.getMinimumHeight(); + if (height <= 0) height = d.getBounds().height(); + return height; + } + + float baseScale; + + public void initBase() { + if (!hasDrawable) return; + if (!isKnowSize) return; + + mBaseMatrix.reset(); + mAnimMatrix.reset(); + isZoomUp = false; + + Drawable img = getDrawable(); + + int w = getWidth(); + int h = getHeight(); + int drawableWidth = getDrawableWidth(img); + int drawableHeight = getDrawableHeight(img); + + mBaseRect.set(0, 0, drawableWidth, drawableHeight); + + // 以图片中心点居中位移 + int tx = (w - drawableWidth) / 2; + int ty = (h - drawableHeight) / 2; + + float sx = 1; + float sy = 1; + + // 缩放,默认不超过屏幕大小 + if (drawableWidth > w) { + sx = (float) w / drawableWidth; + } + + if (drawableHeight > h) { + sy = (float) h / drawableHeight; + } + + baseScale = Math.min(sx, sy); + + mBaseMatrix.reset(); + mBaseMatrix.postTranslate(tx, ty); + mBaseMatrix.postScale(baseScale, baseScale, mScreenCenter.x, mScreenCenter.y); + mBaseMatrix.mapRect(mBaseRect); + + mScaleCenter.set(mScreenCenter); + mRotateCenter.set(mScaleCenter); + + executeTranslate(); + + switch (mScaleType) { + case CENTER: + initCenter(); + break; + case CENTER_CROP: + initCenterCrop(); + break; + case CENTER_INSIDE: + initCenterInside(); + break; + case FIT_CENTER: + initFitCenter(); + break; + case FIT_START: + initFitStart(); + break; + case FIT_END: + initFitEnd(); + break; + case FIT_XY: + initFitXY(); + break; + } + } + + private void initCenter() { + mAnimMatrix.postScale(1, 1, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + } + + private void initCenterCrop() { + float widthScale = mCropRect.width() / mImgRect.width(); + float heightScale = mCropRect.height() / mImgRect.height(); + mScale = Math.max(widthScale, heightScale); + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + } + + private void initCenterInside() { + //控件大于图片,即可完全显示图片,相当于Center,反之,相当于FitCenter + if (mCropRect.width() > mImgRect.width()) { + initCenter(); + } else { + initFitCenter(); + } + float widthScale = mCropRect.width() / mImgRect.width(); + if (widthScale > mMaxScale) { + mMaxScale = widthScale; + } + } + + private void initFitCenter() { + float widthScale = mCropRect.width() / mImgRect.width(); + float heightScale = mCropRect.height() / mImgRect.height(); + mScale = Math.min(widthScale, heightScale); + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + + if (widthScale > mMaxScale) { + mMaxScale = widthScale; + } + } + + private void initFitStart() { + initFitCenter(); + float ty = -mImgRect.top; + mAnimMatrix.postTranslate(0, ty); + executeTranslate(); + resetBase(); + mTranslateY += ty; + } + + private void initFitEnd() { + initFitCenter(); + float ty = (mCropRect.bottom - mImgRect.bottom); + mTranslateY += ty; + mAnimMatrix.postTranslate(0, ty); + executeTranslate(); + resetBase(); + } + + private void initFitXY() { + float widthScale = mCropRect.width() / mImgRect.width(); + float heightScale = mCropRect.height() / mImgRect.height(); + mAnimMatrix.postScale(widthScale, heightScale, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + } + + private void resetBase() { + Drawable img = getDrawable(); + mBaseRect.set(0, 0, getDrawableWidth(img), getDrawableHeight(img)); + mBaseMatrix.set(mSynthesisMatrix); + mBaseMatrix.mapRect(mBaseRect); + mScale = 1; + mTranslateX = 0; + mTranslateY = 0; + mAnimMatrix.reset(); + } + + private void executeTranslate() { + mSynthesisMatrix.set(mBaseMatrix); + mSynthesisMatrix.postConcat(mAnimMatrix); + setImageMatrix(mSynthesisMatrix); + mAnimMatrix.mapRect(mImgRect, mBaseRect); + imgLargeWidth = mImgRect.width() >= mCropRect.width(); + imgLargeHeight = mImgRect.height() >= mCropRect.height(); + } + + private int aspectX = -1, aspectY = -1; + private int cropMargin = 0; + + public void setCropRatio(int aspectX, int aspectY) { + this.aspectX = aspectX; + this.aspectY = aspectY; + if (cropAnim != null && cropAnim.isRunning()) { + cropAnim.cancel(); + } + if (aspectX <= 0 || aspectY <= 0) { + mCropRect.set(0, 0, getWidth(), getHeight()); + mScaleType = ScaleType.CENTER_INSIDE; + initBase(); + invalidate(); + return; + } + + mScaleType = ScaleType.CENTER_CROP; + resetCropSize(getWidth(), getHeight()); + } + + public boolean isEditing() { + return isShowLine; + } + + public void setCropMargin(int cropMargin) { + this.cropMargin = cropMargin; + } + + public int getCropWidth() { + return (int) mCropRect.width(); + } + + public int getCropHeight() { + return (int) mCropRect.height(); + } + + private void resetCropSize(int w, int h) { + float left = 0, top = 0, right = w, bottom = h; + if (aspectY != -1 && aspectX != -1) { + float cropRatio = aspectX * 1.00f / aspectY; + float viewRatio = w * 1.00f / h; + if (h > w) {//view的高>宽 + float top1 = (h - (w - cropMargin * 2) * 1.00f / cropRatio) * 1.00f / 2; + if (cropRatio >= 1) {//宽比例剪裁 + left = cropMargin; + right = w - left; + top = top1; + bottom = h - top; + } else if (cropRatio < 1) {//高比例剪裁 + if (cropRatio > viewRatio) {//剪裁比例大于view宽高比,说明以宽充满,剪裁的高肯定不会超出view的高 + left = cropMargin; + right = w - left; + top = top1; + bottom = h - top; + } else {//剪裁比例小于view宽高比,说明以高充满,宽度肯定不会超过view的宽度 + top = cropMargin; + bottom = h - top; + left = (w - (h - cropMargin * 2) * cropRatio) / 2; + right = w - left; + + } + } + } + anim(left, top, right, bottom); + } else { + mCropRect.set(left, top, right, bottom); + initBase(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + isKnowSize = true; + mScreenCenter.set(w / 2.0f, h / 2.0f); + resetCropSize(w, h); + setImageDrawable(getDrawable()); + } + + public void setRotateEnable(boolean rotateEnable) { + isRotateEnable = rotateEnable; + } + + private boolean isShowLine = false; + private Paint cropRectPaint; + private Paint maskPaint; + private Paint cropStrokePaint; + + private void initCropRect() { + cropRectPaint = new Paint(); + cropRectPaint.setStrokeWidth(dp(2f)); + cropRectPaint.setColor(Color.WHITE); + cropRectPaint.setAntiAlias(true); + cropRectPaint.setStyle(Paint.Style.STROKE); + cropRectPaint.setDither(true); + initMaskPaint(); + } + + private void initCropLineRect() { + linePaint = new Paint(); + linePaint.setColor(Color.WHITE); + linePaint.setAntiAlias(true); + linePaint.setStrokeWidth(dp(0.5f)); + linePaint.setStyle(Paint.Style.FILL); + + cropStrokePaint = new Paint(); + cropStrokePaint.setColor(Color.WHITE); + cropStrokePaint.setAntiAlias(true); + cropStrokePaint.setStrokeCap(Paint.Cap.ROUND); + cropStrokePaint.setStrokeWidth(dp(4)); + cropStrokePaint.setStyle(Paint.Style.STROKE); + } + + private void initMaskPaint() { + maskPaint = new Paint(); + maskPaint.setColor(Color.parseColor("#a0000000")); + maskPaint.setAntiAlias(true); + maskPaint.setStyle(Paint.Style.FILL); + } + + private Rect viewDrawingRect = new Rect(); + private Path path = new Path(); + + private void drawStrokeLine(Canvas canvas) { + int lineWidth = dp(30); + float x = mCropRect.left; + float y = mCropRect.top + dp(1); + float w = mCropRect.width(); + float h = mCropRect.height() - dp(2); + canvas.drawLine(x, y, lineWidth + x, y, cropStrokePaint); + canvas.drawLine(x, y, x, y + lineWidth, cropStrokePaint); + canvas.drawLine(x, y + h, x, y + h - lineWidth, cropStrokePaint); + canvas.drawLine(x, y + h, x + lineWidth, y + h, cropStrokePaint); + canvas.drawLine(x + w, y, x + w - lineWidth, y, cropStrokePaint); + canvas.drawLine(x + w, y, x + w, y + lineWidth, cropStrokePaint); + canvas.drawLine(x + w, y + h, x + w - lineWidth, y + h, cropStrokePaint); + canvas.drawLine(x + w, y + h, x + w, y + h - lineWidth, cropStrokePaint); + } + + private boolean isShowImageRectLine = false; + private boolean canShowTouchLine = true; + private boolean isCircle = false; + + public void setCircle(boolean circle) { + isCircle = circle; + invalidate(); + } + + public void setShowImageRectLine(boolean showImageRectLine) { + isShowImageRectLine = showImageRectLine; + invalidate(); + } + + public void setCanShowTouchLine(boolean canShowTouchLine) { + this.canShowTouchLine = canShowTouchLine; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + try { + super.onDraw(canvas); + } catch (Exception ignored) { + loadMaxSize = (int) (loadMaxSize * 0.8); + setImageBitmap(originalBitmap); + return; + } + + if (isShowLine && canShowTouchLine && !isCircle) { + int left, top, right, bottom, w, h; + if (isShowImageRectLine) { + left = mImgRect.left > mCropRect.left ? (int) mImgRect.left : (int) mCropRect.left; + top = (int) mImgRect.top > mCropRect.top ? (int) mImgRect.top : (int) mCropRect.top; + right = mImgRect.right < mCropRect.right ? (int) mImgRect.right : (int) mCropRect.right; + bottom = mImgRect.bottom < mCropRect.bottom ? (int) mImgRect.bottom : (int) mCropRect.bottom; + w = right - left; + h = bottom - top; + } else { + w = (int) mCropRect.width(); + h = (int) mCropRect.height(); + left = (int) mCropRect.left; + top = (int) mCropRect.top; + } + canvas.drawLine(left + w / 3.0f, top, left + w / 3.0f, h + top, linePaint); + canvas.drawLine(left + w * 2 / 3.0f, top, left + w * 2 / 3.0f, h + top, linePaint); + canvas.drawLine(left, top + h / 3.0f, left + w, top + h / 3.0f, linePaint); + canvas.drawLine(left, top + h * 2 / 3.0f, left + w, top + h * 2 / 3.0f, linePaint); + } + + if (!isShowCropRect || aspectY <= 0 || aspectX <= 0) { + return; + } + + getDrawingRect(viewDrawingRect); + path.reset(); + if (isCircle) { + path.addCircle(mCropRect.left + mCropRect.width() / 2, mCropRect.top + mCropRect.height() / 2, mCropRect.width() / 2, Path.Direction.CW); + } else { + drawStrokeLine(canvas); + path.addRect(mCropRect.left, mCropRect.top, mCropRect.right, mCropRect.bottom, Path.Direction.CW); + } + canvas.clipPath(path, android.graphics.Region.Op.DIFFERENCE); + canvas.drawRect(viewDrawingRect, maskPaint); + canvas.drawPath(path, cropRectPaint); + } + + @Override + public void draw(Canvas canvas) { + if (mClip != null) { + canvas.clipRect(mClip); + mClip = null; + } + super.draw(canvas); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (isEnable) { + final int Action = event.getActionMasked(); + if (event.getPointerCount() >= 2) hasMultiTouch = true; + + mDetector.onTouchEvent(event); + if (isRotateEnable) { + mRotateDetector.onTouchEvent(event); + } + mScaleDetector.onTouchEvent(event); + if (Action == MotionEvent.ACTION_DOWN) { + isShowLine = true; + invalidate(); + } else if (Action == MotionEvent.ACTION_UP || Action == MotionEvent.ACTION_CANCEL) { + onUp(); + isShowLine = false; + invalidate(); + } + + return true; + } else { + return super.dispatchTouchEvent(event); + } + } + + private void onUp() { + if (mTranslate.isRunning) + return; + + if (canRotate || mDegrees % 90 != 0) { + float toDegrees = (int) (mDegrees / 90) * 90; + float remainder = mDegrees % 90; + + if (remainder > 45) + toDegrees += 90; + else if (remainder < -45) + toDegrees -= 90; + + mTranslate.withRotate((int) mDegrees, (int) toDegrees); + + mDegrees = toDegrees; + } + + if (!isBounceEnable) { + return; + } + + float cx = mImgRect.left * 1.00f + mImgRect.width() / 2; + float cy = mImgRect.top * 1.00f + mImgRect.height() / 2; + + mRotateCenter.set(cx, cy); + + if (mScale < 1) { + mTranslate.withScale(mScale, 1); + mScale = 1; + } else if (mScale > mMaxScale) { + mTranslate.withScale(mScale, mMaxScale); + mScale = mMaxScale; + } + + mScaleCenter.set(cx, cy); + mTranslateX = 0; + mTranslateY = 0; + + mTmpMatrix.reset(); + mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mTmpMatrix.postTranslate(cx - mBaseRect.width() / 2, cy - mBaseRect.height() / 2); + mTmpMatrix.postScale(mScale, mScale, mScaleCenter.x, mScaleCenter.y); + mTmpMatrix.postRotate(mDegrees, cx, cy); + mTmpMatrix.mapRect(mTmpRect, mBaseRect); + + doTranslateReset(mTmpRect); + mTranslate.start(); + } + + private boolean isBounceEnable = true; + + public void setBounceEnable(boolean isBounceEnable) { + this.isBounceEnable = isBounceEnable; + } + + private void doTranslateReset(RectF imgRect) { + int tx = 0; + int ty = 0; + + int width = (int) (mCropRect.width()); + int height = (int) (mCropRect.height()); + + if (imgRect.width() <= width) { + if (!isImageCenterWidth(imgRect)) { + if (aspectX > 0 && aspectY > 0) { + tx = (int) (imgRect.left - mCropRect.left); + } else { + tx = -(int) ((mCropRect.width() - imgRect.width()) / 2 - imgRect.left); + } + } + } else { + if (imgRect.left > mCropRect.left) { + tx = (int) (imgRect.left - mCropRect.left); + } else if (imgRect.right < mCropRect.right) { + tx = (int) (imgRect.right - mCropRect.right); + } + } + + if (imgRect.height() <= height) { + if (!isImageCenterHeight(imgRect)) + if (aspectX > 0 && aspectY > 0) { + ty = (int) (imgRect.top - mCropRect.top); + } else { + ty = -(int) ((mCropRect.height() - imgRect.height()) / 2 - imgRect.top); + } + } else { + if (imgRect.top > mCropRect.top) { + ty = (int) (imgRect.top - mCropRect.top); + } else if (imgRect.bottom < mCropRect.bottom) { + ty = (int) (imgRect.bottom - mCropRect.bottom); + } + } + + if (tx != 0 || ty != 0) { + if (!mTranslate.mFlingScroller.isFinished()) mTranslate.mFlingScroller.abortAnimation(); + mTranslate.withTranslate(mTranslateX, mTranslateY, -tx, -ty); + } + } + + private boolean isImageCenterHeight(RectF rect) { + return Math.abs(Math.round(rect.top) - (mCropRect.height() - rect.height()) / 2) < 1; + } + + private boolean isImageCenterWidth(RectF rect) { + return Math.abs(Math.round(rect.left) - (mCropRect.width() - rect.width()) / 2) < 1; + } + + private RotateGestureDetector.OnRotateListener mRotateListener = new RotateGestureDetector.OnRotateListener() { + @Override + public void onRotate(float degrees, float focusX, float focusY) { + mRotateFlag += degrees; + if (canRotate) { + mDegrees += degrees; + mAnimMatrix.postRotate(degrees, focusX, focusY); + } else { + if (Math.abs(mRotateFlag) >= mMinRotate) { + canRotate = true; + mRotateFlag = 0; + } + } + } + }; + + private ScaleGestureDetector.OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector detector) { + float scaleFactor = detector.getScaleFactor(); + + if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) + return false; + + if (mScale > mMaxScale) { + return true; + } + + mScale *= scaleFactor; + mScaleCenter.set(detector.getFocusX(), detector.getFocusY()); + mAnimMatrix.postScale(scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY()); + executeTranslate(); + return true; + } + + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + public void onScaleEnd(ScaleGestureDetector detector) { + } + }; + + private float resistanceScrollByX(float overScroll, float detalX) { + return detalX * (Math.abs(Math.abs(overScroll) - MAX_OVER_RESISTANCE) / (float) MAX_OVER_RESISTANCE); + } + + private float resistanceScrollByY(float overScroll, float detalY) { + return detalY * (Math.abs(Math.abs(overScroll) - MAX_OVER_RESISTANCE) / (float) MAX_OVER_RESISTANCE); + } + + /** + * 匹配两个Rect的共同部分输出到out,若无共同部分则输出0,0,0,0 + */ + private void mapRect(RectF r1, RectF r2, RectF out) { + float l, r, t, b; + + l = r1.left > r2.left ? r1.left : r2.left; + r = r1.right < r2.right ? r1.right : r2.right; + + if (l > r) { + out.set(0, 0, 0, 0); + return; + } + + t = r1.top > r2.top ? r1.top : r2.top; + b = r1.bottom < r2.bottom ? r1.bottom : r2.bottom; + + if (t > b) { + out.set(0, 0, 0, 0); + return; + } + + out.set(l, t, r, b); + } + + private void checkRect() { + if (!hasOverTranslate) { + mapRect(mCropRect, mImgRect, mCommonRect); + } + } + + private Runnable mClickRunnable = new Runnable() { + @Override + public void run() { + if (mClickListener != null) { + mClickListener.onClick(CropImageView.this); + } + } + }; + + private GestureDetector.OnGestureListener mGestureListener = new GestureDetector.SimpleOnGestureListener() { + @Override + public void onLongPress(MotionEvent e) { + if (mLongClick != null) { + mLongClick.onLongClick(CropImageView.this); + } + } + + @Override + public boolean onDown(MotionEvent e) { + hasOverTranslate = false; + hasMultiTouch = false; + canRotate = false; + removeCallbacks(mClickRunnable); + return false; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (hasMultiTouch) return false; + if (!imgLargeWidth && !imgLargeHeight) return false; + if (mTranslate.isRunning) return false; + + float vx = velocityX; + float vy = velocityY; + + if (Math.round(mImgRect.left) >= mCropRect.left || Math.round(mImgRect.right) <= mCropRect.right) { + vx = 0; + } + + if (Math.round(mImgRect.top) >= mCropRect.top || Math.round(mImgRect.bottom) <= mCropRect.bottom) { + vy = 0; + } + + if (canRotate || mDegrees % 90 != 0) { + float toDegrees = (int) (mDegrees / 90) * 90; + float remainder = mDegrees % 90; + + if (remainder > 45) + toDegrees += 90; + else if (remainder < -45) + toDegrees -= 90; + + mTranslate.withRotate((int) mDegrees, (int) toDegrees); + mDegrees = toDegrees; + } + mTranslate.withFling(vx, vy); + return super.onFling(e1, e2, velocityX, velocityY); + } + + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (mTranslate.isRunning) { + mTranslate.stop(); + } + if (canScrollHorizontallySelf(distanceX)) { + if (distanceX < 0 && mImgRect.left - distanceX > mCropRect.left) + distanceX = mImgRect.left; + if (distanceX > 0 && mImgRect.right - distanceX < mCropRect.right) + distanceX = mImgRect.right - mCropRect.right; + + mAnimMatrix.postTranslate(-distanceX, 0); + mTranslateX -= distanceX; + } else if (imgLargeWidth || hasMultiTouch || hasOverTranslate || !isBounceEnable) { + checkRect(); + if (!hasMultiTouch || !isBounceEnable) { + if (distanceX < 0 && mImgRect.left - distanceX > mCommonRect.left) + distanceX = resistanceScrollByX(mImgRect.left - mCommonRect.left, distanceX); + if (distanceX > 0 && mImgRect.right - distanceX < mCommonRect.right) + distanceX = resistanceScrollByX(mImgRect.right - mCommonRect.right, distanceX); + } + + mTranslateX -= distanceX; + mAnimMatrix.postTranslate(-distanceX, 0); + hasOverTranslate = true; + } + + if (canScrollVerticallySelf(distanceY)) { + if (distanceY < 0 && mImgRect.top - distanceY > mCropRect.top) + distanceY = mImgRect.top; + if (distanceY > 0 && mImgRect.bottom - distanceY < mCropRect.bottom) + distanceY = mImgRect.bottom - mCropRect.bottom; + + mAnimMatrix.postTranslate(0, -distanceY); + mTranslateY -= distanceY; + } else if (imgLargeHeight || hasOverTranslate || hasMultiTouch || !isBounceEnable) { + checkRect(); + if (!hasMultiTouch || !isBounceEnable) { + if (distanceY < 0 && mImgRect.top - distanceY > mCommonRect.top) + distanceY = resistanceScrollByY(mImgRect.top - mCommonRect.top, distanceY); + if (distanceY > 0 && mImgRect.bottom - distanceY < mCommonRect.bottom) + distanceY = resistanceScrollByY(mImgRect.bottom - mCommonRect.bottom, distanceY); + } + + mAnimMatrix.postTranslate(0, -distanceY); + mTranslateY -= distanceY; + hasOverTranslate = true; + } + + executeTranslate(); + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + postDelayed(mClickRunnable, 250); + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + mTranslate.stop(); + + float from; + float to; + + float imageCenterX = mImgRect.left + mImgRect.width() / 2; + float imageCenterY = mImgRect.top + mImgRect.height() / 2; + + mScaleCenter.set(imageCenterX, imageCenterY); + mRotateCenter.set(imageCenterX, imageCenterY); + mTranslateX = 0; + mTranslateY = 0; + + if (mScale > 1) { + from = mScale; + to = 1; + } else { + from = mScale; + to = mMaxScale; + mScaleCenter.set(e.getX(), e.getY()); + } + + mTmpMatrix.reset(); + mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mTmpMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y); + mTmpMatrix.postTranslate(-mBaseRect.width() / 2, -mBaseRect.height() / 2); + mTmpMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y); + mTmpMatrix.postScale(to, to, mScaleCenter.x, mScaleCenter.y); + mTmpMatrix.postTranslate(mTranslateX, mTranslateY); + mTmpMatrix.mapRect(mTmpRect, mBaseRect); + doTranslateReset(mTmpRect); + + isZoomUp = !isZoomUp; + mTranslate.withScale(from, to); + mTranslate.start(); + + return false; + } + }; + + public boolean canScrollHorizontallySelf(float direction) { + if (mImgRect.width() <= mCropRect.width()) return false; + if (direction < 0 && Math.round(mImgRect.left) - direction >= mCropRect.left) + return false; + return !(direction > 0) || !(Math.round(mImgRect.right) - direction <= mCropRect.right); + } + + public boolean canScrollVerticallySelf(float direction) { + if (mImgRect.height() <= mCropRect.height()) return false; + if (direction < 0 && Math.round(mImgRect.top) - direction >= mCropRect.top) + return false; + return !(direction > 0) || !(Math.round(mImgRect.bottom) - direction <= mCropRect.bottom); + } + + @Override + public boolean canScrollHorizontally(int direction) { + if (!isEnable) { + return super.canScrollHorizontally(direction); + } + if (hasMultiTouch) return true; + return canScrollHorizontallySelf(direction); + } + + @Override + public boolean canScrollVertically(int direction) { + if (!isEnable) { + return super.canScrollVertically(direction); + } + if (hasMultiTouch) return true; + return canScrollVerticallySelf(direction); + } + + private class InterpolatorProxy implements Interpolator { + + private Interpolator mTarget; + + private InterpolatorProxy() { + mTarget = new DecelerateInterpolator(); + } + + void setTargetInterpolator(Interpolator interpolator) { + mTarget = interpolator; + } + + @Override + public float getInterpolation(float input) { + if (mTarget != null) { + return mTarget.getInterpolation(input); + } + return input; + } + } + + private class Transform implements Runnable { + + boolean isRunning; + + OverScroller mTranslateScroller; + OverScroller mFlingScroller; + Scroller mScaleScroller; + Scroller mClipScroller; + Scroller mRotateScroller; + + ClipCalculate C; + + int mLastFlingX; + int mLastFlingY; + + int mLastTranslateX; + int mLastTranslateY; + + RectF mClipRect = new RectF(); + + InterpolatorProxy mInterpolatorProxy = new InterpolatorProxy(); + + Transform() { + Context ctx = getContext(); + mTranslateScroller = new OverScroller(ctx, mInterpolatorProxy); + mScaleScroller = new Scroller(ctx, mInterpolatorProxy); + mFlingScroller = new OverScroller(ctx, mInterpolatorProxy); + mClipScroller = new Scroller(ctx, mInterpolatorProxy); + mRotateScroller = new Scroller(ctx, mInterpolatorProxy); + } + + public void setInterpolator(Interpolator interpolator) { + mInterpolatorProxy.setTargetInterpolator(interpolator); + } + + void withTranslate(int startX, int startY, int deltaX, int deltaY) { + mLastTranslateX = 0; + mLastTranslateY = 0; + mTranslateScroller.startScroll(startX, startY, deltaX, deltaY, mAnimDuring); + } + + void withScale(float form, float to) { + mScaleScroller.startScroll((int) (form * 10000), 0, (int) ((to - form) * 10000), 0, mAnimDuring); + } + + void withRotate(int fromDegrees, int toDegrees) { + mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, mAnimDuring); + } + + void withRotate(int fromDegrees, int toDegrees, int during) { + mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, during); + } + + void withFling(float velocityX, float velocityY) { + mLastFlingX = velocityX < 0 ? Integer.MAX_VALUE : 0; + int distanceX = (int) (velocityX > 0 ? Math.abs(mImgRect.left) : mImgRect.right - mCropRect.right); + distanceX = velocityX < 0 ? Integer.MAX_VALUE - distanceX : distanceX; + int minX = velocityX < 0 ? distanceX : 0; + int maxX = velocityX < 0 ? Integer.MAX_VALUE : distanceX; + int overX = velocityX < 0 ? Integer.MAX_VALUE - minX : distanceX; + + mLastFlingY = velocityY < 0 ? Integer.MAX_VALUE : 0; + int distanceY = (int) (velocityY > 0 ? Math.abs(mImgRect.top - mCropRect.top) : mImgRect.bottom - mCropRect.bottom); + distanceY = velocityY < 0 ? Integer.MAX_VALUE - distanceY : distanceY; + int minY = velocityY < 0 ? distanceY : 0; + int maxY = velocityY < 0 ? Integer.MAX_VALUE : distanceY; + int overY = velocityY < 0 ? Integer.MAX_VALUE - minY : distanceY; + + if (velocityX == 0) { + maxX = 0; + minX = 0; + } + + if (velocityY == 0) { + maxY = 0; + minY = 0; + } + + mFlingScroller.fling(mLastFlingX, mLastFlingY, (int) velocityX, (int) velocityY, minX, maxX, minY, maxY, + Math.abs(overX) < MAX_FLING_OVER_SCROLL * 2 ? 0 : MAX_FLING_OVER_SCROLL, + Math.abs(overY) < MAX_FLING_OVER_SCROLL * 2 ? 0 : MAX_FLING_OVER_SCROLL); + } + + void start() { + isRunning = true; + postExecute(); + } + + void stop() { + removeCallbacks(this); + mTranslateScroller.abortAnimation(); + mScaleScroller.abortAnimation(); + mFlingScroller.abortAnimation(); + mRotateScroller.abortAnimation(); + isRunning = false; + } + + @Override + public void run() { + if (!isRunning) return; + + boolean endAnim = true; + + if (mScaleScroller.computeScrollOffset()) { + mScale = mScaleScroller.getCurrX() / 10000f; + endAnim = false; + } + + if (mTranslateScroller.computeScrollOffset()) { + int tx = mTranslateScroller.getCurrX() - mLastTranslateX; + int ty = mTranslateScroller.getCurrY() - mLastTranslateY; + mTranslateX += tx; + mTranslateY += ty; + mLastTranslateX = mTranslateScroller.getCurrX(); + mLastTranslateY = mTranslateScroller.getCurrY(); + endAnim = false; + } + + if (mFlingScroller.computeScrollOffset()) { + int x = mFlingScroller.getCurrX() - mLastFlingX; + int y = mFlingScroller.getCurrY() - mLastFlingY; + + mLastFlingX = mFlingScroller.getCurrX(); + mLastFlingY = mFlingScroller.getCurrY(); + + mTranslateX += x; + mTranslateY += y; + endAnim = false; + } + + if (mRotateScroller.computeScrollOffset()) { + mDegrees = mRotateScroller.getCurrX(); + endAnim = false; + } + + if (mClipScroller.computeScrollOffset() || mClip != null) { + float sx = mClipScroller.getCurrX() / 10000f; + float sy = mClipScroller.getCurrY() / 10000f; + mTmpMatrix.setScale(sx, sy, (mImgRect.left + mImgRect.right) / 2, C.calculateTop()); + mTmpMatrix.mapRect(mClipRect, mImgRect); + + if (sx == 1) { + mClipRect.left = mCropRect.left; + mClipRect.right = mCropRect.right; + } + + if (sy == 1) { + mClipRect.top = mCropRect.top; + mClipRect.bottom = mCropRect.bottom; + } + + mClip = mClipRect; + } + if (!endAnim) { + applyAnim(); + postExecute(); + } else { + isRunning = false; + if (aspectX > 0 && aspectY > 0) { + return; + } + // 修复动画结束后边距有些空隙, + boolean needFix = false; + if (imgLargeWidth) { + if (mImgRect.left > 0) { + mTranslateX -= mCropRect.left; + } else if (mImgRect.right < mCropRect.width()) { + mTranslateX -= (int) (mCropRect.width() - mImgRect.right); + } + needFix = true; + } + + if (imgLargeHeight) { + if (mImgRect.top > 0) { + mTranslateY -= mCropRect.top; + } else if (mImgRect.bottom < mCropRect.height()) { + mTranslateY -= (int) (mCropRect.height() - mImgRect.bottom); + } + needFix = true; + } + + if (needFix) { + applyAnim(); + } + + invalidate(); + } + if (mCompleteCallBack != null) { + mCompleteCallBack.run(); + mCompleteCallBack = null; + } + } + + private void applyAnim() { + mAnimMatrix.reset(); + mAnimMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mAnimMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y); + mAnimMatrix.postTranslate(-mBaseRect.width() / 2, -mBaseRect.height() / 2); + mAnimMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y); + mAnimMatrix.postScale(mScale, mScale, mScaleCenter.x, mScaleCenter.y); + mAnimMatrix.postTranslate(mTranslateX, mTranslateY); + executeTranslate(); + } + + private void postExecute() { + if (isRunning) post(this); + } + } + + public Info getInfo() { + return new Info(mImgRect, mCropRect, mDegrees, mScaleType.name(), aspectX, aspectY, getTranslateX(), getTranslateY(), getScale()); + } + + public interface ClipCalculate { + float calculateTop(); + } + + public void rotate(float degrees) { + mDegrees += degrees; + int centerX = (int) (mCropRect.left + mCropRect.width() / 2); + int centerY = (int) (mCropRect.top + mCropRect.height() / 2); + + mAnimMatrix.postRotate(degrees, centerX, centerY); + executeTranslate(); + } + + public Bitmap generateCropBitmapFromView(final int backgroundColor) { + ((Activity) getContext()).runOnUiThread(new Runnable() { + @Override + public void run() { + setShowImageRectLine(false); + isShowCropRect = false; + invalidate(); + } + }); + + Bitmap bitmap = PBitmapUtils.getViewBitmap(CropImageView.this); + try { + bitmap = Bitmap.createBitmap(bitmap, (int) mCropRect.left, (int) mCropRect.top, + (int) mCropRect.width(), (int) mCropRect.height()); + if (isCircle) { + bitmap = createCircleBitmap(bitmap, backgroundColor); + } + } catch (Exception ignored) { + } + return bitmap; + } + + /** + * 生成剪裁图片 + * + * @return bitmap + */ + public Bitmap generateCropBitmap() { + if (originalBitmap == null) { + return null; + } + //水平平移像素点 + float x = Math.abs(getTranslateX()); + //垂直平移像素点 + float y = Math.abs(getTranslateY()); + //缩放比例 + float scale = mScale; + //原图宽度(Glide压缩过的,Glide默认加载会减小大图的宽高) + int bw = originalBitmap.getWidth(); + //原图高度(Glide压缩过的) + int bh = originalBitmap.getHeight(); + //图片宽高比 + float bRatio = bw * 1.00f / (bh * 1.00f); + + float endW; + float endH; + float endX; + float endY; + + float cropWidth = mCropRect.width(); + float cropHeight = mCropRect.height(); + float cropRatio = (cropWidth * 1.00f / (cropHeight * 1.00f)); + + //图片比例小于剪裁比例,以宽填满,高自适应,计算高 + if (bRatio < cropRatio) { + endW = bw / scale; + endH = endW / cropRatio; + endX = bw * x / (cropWidth * scale * 1.00f); + endY = bw * y / (cropWidth * scale * 1.00f); + } else { + endH = bh / scale; + endW = cropRatio * endH; + endX = bh * x / (cropHeight * scale * 1.00f); + endY = bh * y / (cropHeight * scale * 1.00f); + } + + if (endX + endW > bw) { + endX = bw - endW; + if (endX < 0) { + endX = 0; + } + } + + if (endY + endH > bh) { + endY = bh - endH; + if (endY < 0) { + endY = 0; + } + } + + Bitmap bitmap1; + try { + bitmap1 = Bitmap.createBitmap(originalBitmap, (int) endX, (int) endY, (int) endW, (int) endH); +// if (isCircle) { +// bitmap1 = createCircleBitmap(bitmap1, Color.TRANSPARENT); +// } + } catch (Exception ignored) { + bitmap1 = generateCropBitmapFromView(Color.BLACK); + } + return bitmap1; + } + + public void setShowCropRect(boolean showCropRect) { + isShowCropRect = showCropRect; + invalidate(); + } + + private Bitmap createCircleBitmap(Bitmap resource, int backgroundColor) { + int width = resource.getWidth(); + Paint paint = new Paint(); + paint.setAntiAlias(true); + Bitmap circleBitmap = Bitmap.createBitmap(resource.getWidth(), resource.getHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(circleBitmap); + if (backgroundColor != Color.TRANSPARENT) { + paint.setColor(backgroundColor); + } + canvas.drawCircle(width / 2, width / 2, width / 2, paint); + //设置画笔为取交集模式 + if (backgroundColor == Color.TRANSPARENT) { + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + } else { + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)); + } + + //裁剪图片 + canvas.drawBitmap(resource, 0, 0, paint); + return circleBitmap; + } + + + public float getTranslateX() { + return mImgRect.left - mCropRect.left; + } + + public float getTranslateY() { + return mImgRect.top - mCropRect.top; + } + + public float getScale() { + if (mScale <= 1) { + return 1; + } + return mScale; + } + + public int dp(float dp) { + float density = getContext().getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + + private ValueAnimator cropAnim; + + private void anim(float left, float top, float right, float bottom) { + final float oldLeft = mCropRect.left; + final float oldTop = mCropRect.top; + final float oldRight = mCropRect.right; + final float oldBottom = mCropRect.bottom; + final float finalLeft = left; + final float finalTop = top; + final float finalRight = right; + final float finalBottom = bottom; + + if ((oldRight == 0 || oldBottom == 0) || (oldLeft == left && oldBottom == bottom + && oldRight == right && oldTop == top)) { + mCropRect.set(finalLeft, finalTop, finalRight, finalBottom); + initBase(); + invalidate(); + return; + } + + if (cropAnim == null) { + cropAnim = ObjectAnimator.ofFloat(0.0F, 1.0F).setDuration(400); + cropAnim.setInterpolator(new DecelerateInterpolator()); + } + cropAnim.removeAllUpdateListeners(); + cropAnim.removeAllListeners(); + cropAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float value = (float) animation.getAnimatedValue(); + mCropRect.left = (finalLeft - oldLeft) * value + oldLeft; + mCropRect.top = (finalTop - oldTop) * value + oldTop; + mCropRect.right = (finalRight - oldRight) * value + oldRight; + mCropRect.bottom = (finalBottom - oldBottom) * value + oldBottom; + isShowLine = value < 1.0f; + initBase(); + invalidate(); + } + }); + cropAnim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + initBase(); + invalidate(); + } + }); + cropAnim.start(); + } + + public void changeSize(boolean isAnim, final int endWidth, final int endHeight) { + if (isAnim) { + final int startWidth = getWidth(); + final int startHeight = getHeight(); + ValueAnimator anim = ValueAnimator.ofFloat(0.0f, 1.0f).setDuration(200); + anim.setInterpolator(new DecelerateInterpolator()); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float ratio = (Float) animation.getAnimatedValue(); + ViewGroup.LayoutParams params = getLayoutParams(); + params.width = (int) ((endWidth - startWidth) * ratio + startWidth); + params.height = (int) ((endHeight - startHeight) * ratio + startHeight); + setLayoutParams(params); + setImageDrawable(getDrawable()); + } + }); + anim.start(); + } else { + ViewGroup.LayoutParams params = getLayoutParams(); + params.width = endWidth; + params.height = endHeight; + setLayoutParams(params); + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java new file mode 100644 index 0000000..f4d3d44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java @@ -0,0 +1,106 @@ +package com.remax.visualnovel.widget.imagepicker.widget.cropimage; + +import android.graphics.RectF; +import android.os.Parcel; +import android.os.Parcelable; +import android.widget.ImageView; + +import java.io.Serializable; + +/** + * Description: 图片基本信息 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class Info implements Parcelable, Serializable { + // 控件在窗口的位置 + public RectF mImgRect = new RectF(); + public RectF mWidgetRect = new RectF(); + + public float mDegrees; + public float mCropX; + public float mCropY; + public String mScaleType; + + public float transitX; + public float transitY; + public float mScale; + + public ImageView.ScaleType getScaleType() { + return ImageView.ScaleType.valueOf(mScaleType); + } + + + public Info(RectF img, RectF widget, float degrees, String scaleType, float mCropX, + float mCropY, float transitX, float transitY, float mScale) { + mImgRect.set(img); + mWidgetRect.set(widget); + mScaleType = scaleType; + mDegrees = degrees; + this.mCropX = mCropX; + this.mCropY = mCropY; + this.transitX = transitX; + this.transitY = transitY; + this.mScale = mScale; + } + + protected Info(Parcel in) { + mImgRect = in.readParcelable(RectF.class.getClassLoader()); + mWidgetRect = in.readParcelable(RectF.class.getClassLoader()); + mScaleType = in.readString(); + mDegrees = in.readFloat(); + mCropX = in.readFloat(); + mCropY = in.readFloat(); + this.transitX = in.readFloat();; + this.transitY = in.readFloat();; + this.mScale = in.readFloat();; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Info createFromParcel(Parcel in) { + return new Info(in); + } + + @Override + public Info[] newArray(int size) { + return new Info[size]; + } + }; + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(mImgRect, flags); + dest.writeParcelable(mWidgetRect, flags); + dest.writeString(mScaleType); + dest.writeFloat(mDegrees); + dest.writeFloat(mCropX); + dest.writeFloat(mCropY); + dest.writeFloat(transitX); + dest.writeFloat(transitY); + dest.writeFloat(mScale); + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java new file mode 100644 index 0000000..09b30ae --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java @@ -0,0 +1,70 @@ +package com.remax.visualnovel.widget.imagepicker.widget.cropimage; + +import android.view.MotionEvent; + +/** + * Description: 旋转手势 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class RotateGestureDetector { + + private static final int MAX_DEGREES_STEP = 120; + + private OnRotateListener mListener; + + private float mPrevSlope; + private float mCurrSlope; + + private float x1; + private float y1; + private float x2; + private float y2; + + public RotateGestureDetector(OnRotateListener l) { + mListener = l; + } + + public void onTouchEvent(MotionEvent event) { + + final int Action = event.getActionMasked(); + + switch (Action) { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_POINTER_UP: + if (event.getPointerCount() == 2) mPrevSlope = caculateSlope(event); + break; + case MotionEvent.ACTION_MOVE: + if (event.getPointerCount() > 1) { + mCurrSlope = caculateSlope(event); + + double currDegrees = Math.toDegrees(Math.atan(mCurrSlope)); + double prevDegrees = Math.toDegrees(Math.atan(mPrevSlope)); + + double deltaSlope = currDegrees - prevDegrees; + + if (Math.abs(deltaSlope) <= MAX_DEGREES_STEP) { + mListener.onRotate((float) deltaSlope, (x2 + x1) / 2, (y2 + y1) / 2); + } + mPrevSlope = mCurrSlope; + } + break; + default: + break; + } + } + + private float caculateSlope(MotionEvent event) { + x1 = event.getX(0); + y1 = event.getY(0); + x2 = event.getX(1); + y2 = event.getY(1); + return (y2 - y1) / (x2 - x1); + } + + public interface OnRotateListener { + void onRotate(float degrees, float focusX, float focusY); + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt new file mode 100644 index 0000000..01b801d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt @@ -0,0 +1,90 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.app.Dialog +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import com.remax.visualnovel.R + +open class BaseDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return Dialog(requireActivity(), R.style.FullScreenDialog).apply { + setCanceledOnTouchOutside(true) + window?.let(::setWindow) + } + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.isFocusableInTouchMode = true + view.setOnKeyListener { _, keyCode, event -> + val backPressed = event.action == MotionEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK + if (backPressed) onBackPressed() + backPressed + } + } + + override fun onResume() { + super.onResume() + view?.requestFocus() + } + + override fun onDestroyView() { + super.onDestroyView() + view?.setOnKeyListener(null) + } + + open fun setWindow(win: Window) { + win.setWindowAnimations(R.style.Animation_Keep) +// win.decorView.setPadding(0, 0, 0, 0) +// val lp = win.attributes +// lp.width = WindowManager.LayoutParams.MATCH_PARENT +// lp.height = WindowManager.LayoutParams.MATCH_PARENT +// win.attributes = lp +// win.setGravity(Gravity.CENTER) + val layoutParams = WindowManager.LayoutParams() + layoutParams.copyFrom(win.attributes) + layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT + layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT + + // 设置系统UI的可见性,让内容延伸到状态栏和导航栏后面 + win.attributes = layoutParams + + // 设置系统UI的Flag,使内容可以延伸到状态栏和导航栏区域 + win.decorView.setSystemUiVisibility( + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + ) + + // 设置状态栏和导航栏透明 + win.statusBarColor = android.graphics.Color.TRANSPARENT + win.navigationBarColor = android.graphics.Color.TRANSPARENT + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + win.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + } + + fun show(fragmentManager: FragmentManager?) { + when { + fragmentManager == null -> showFailure()//"fragmentManager is detach after parent destroy" + fragmentManager.isStateSaved -> showFailure()//"dialog fragment show when fragmentManager isStateSaved" + else -> show(fragmentManager, javaClass.simpleName) + } + } + + open fun showFailure(message: String? = null) { + } + + open fun onBackPressed() { + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt new file mode 100644 index 0000000..00174d7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt @@ -0,0 +1,21 @@ +package com.remax.visualnovel.widget.imageviewer + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.remax.visualnovel.widget.imageviewer.ViewerActions +import com.remax.visualnovel.widget.imageviewer.core.Photo + +class ImageViewerActionViewModel : ViewModel() { + private val _actionEvent = MutableLiveData?>() + val actionEvent: LiveData?> = _actionEvent + + fun setCurrentItem(pos: Int) = internalHandle(ViewerActions.SET_CURRENT_ITEM, pos) + fun dismiss() = internalHandle(ViewerActions.DISMISS, null) + fun remove(item: List) = internalHandle(ViewerActions.REMOVE_ITEMS, item) + + private fun internalHandle(action: String, extra: Any?) { + _actionEvent.value = Pair(action, extra) + _actionEvent.value = null + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt new file mode 100644 index 0000000..11c09e0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +interface ImageViewerAdapterListener { + fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) + fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) + fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) + fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt new file mode 100644 index 0000000..4b3924a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt @@ -0,0 +1,61 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.remax.visualnovel.widget.imageviewer.ImageViewerDialogFragment +import com.remax.visualnovel.widget.imageviewer.core.Components +import com.remax.visualnovel.widget.imageviewer.core.DataProvider +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.OverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Transformer +import com.remax.visualnovel.widget.imageviewer.core.VHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.ViewerCallback + +class ImageViewerBuilder( + private val context: Context?, + val imageLoader: ImageLoader, + private val dataProvider: DataProvider, + private val transformer: Transformer, +) { + private var vhCustomizer: VHCustomizer? = null + private var viewerCallback: ViewerCallback? = null + private var overlayCustomizer: OverlayCustomizer? = null + private var factory: ImageViewerDialogFragment.Factory? = null + + fun setVHCustomizer(vhCustomizer: VHCustomizer): ImageViewerBuilder { + this.vhCustomizer = vhCustomizer + return this + } + + fun setViewerCallback(viewerCallback: ViewerCallback): ImageViewerBuilder { + this.viewerCallback = viewerCallback + return this + } + + fun setOverlayCustomizer(overlayCustomizer: OverlayCustomizer?): ImageViewerBuilder { + this.overlayCustomizer = overlayCustomizer + return this + } + + fun setViewerFactory(factory: ImageViewerDialogFragment.Factory?): ImageViewerBuilder { + this.factory = factory + return this + } + + private fun create(): ImageViewerDialogFragment { + return (factory ?: ImageViewerDialogFragment.Factory()).build() + } + + fun show() { + if (Components.working) return + (context as? FragmentActivity?)?.let { + Components.initialize(imageLoader, dataProvider, transformer) + Components.setVHCustomizer(vhCustomizer) + Components.setViewerCallback(viewerCallback) + Components.setOverlayCustomizer(overlayCustomizer) + val viewer = create() + viewer.show(it.supportFragmentManager) + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt new file mode 100644 index 0000000..c6dc253 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt @@ -0,0 +1,190 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.graphics.Color +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.Window +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.FragmentImageViewerDialogBinding +import com.remax.visualnovel.widget.imageviewer.adapter.ImageViewerAdapter +import com.remax.visualnovel.widget.imageviewer.core.Components +import com.remax.visualnovel.widget.imageviewer.core.Components.requireDataProvider +import com.remax.visualnovel.widget.imageviewer.core.Components.requireOverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Components.requireTransformer +import com.remax.visualnovel.widget.imageviewer.core.Components.requireViewerCallback +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.utils.TransitionEndHelper +import com.remax.visualnovel.widget.imageviewer.utils.TransitionStartHelper +import com.remax.visualnovel.widget.imageviewer.utils.findViewWithKeyTag +import kotlin.math.max + +open class ImageViewerDialogFragment : BaseDialogFragment() { + private var innerBinding: FragmentImageViewerDialogBinding? = null + private val binding get() = innerBinding!! + private val viewModel by lazy { ViewModelProvider(this)[ImageViewerViewModel::class.java] } + private val actions by lazy { ViewModelProvider(requireActivity())[ImageViewerActionViewModel::class.java] } + private val userCallback by lazy { requireViewerCallback() } + private val initKey by lazy { requireDataProvider().loadInitial().first().id() } + private val transformer by lazy { requireTransformer() } + private val adapter by lazy { ImageViewerAdapter(initKey) } + private val taskId = 110 + private var submitPagingData = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!Components.working) dismissAllowingStateLoss() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + innerBinding = + innerBinding ?: FragmentImageViewerDialogBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + adapter.setListener(adapterListener) + (binding.viewer.getChildAt(0) as? RecyclerView?)?.let { + it.clipChildren = false + it.itemAnimator = null + } + binding.viewer.registerOnPageChangeCallback(pagerCallback) + binding.viewer.orientation = Config.VIEWER_ORIENTATION + binding.viewer.adapter = adapter + + requireOverlayCustomizer().provideView(binding.overlayView)?.let(binding.overlayView::addView) + + viewModel.pagingData.observe(viewLifecycleOwner) { + submitPagingData = true + adapter.submitData(viewLifecycleOwner.lifecycle, it) + } + viewModel.viewerUserInputEnabled.observe(viewLifecycleOwner) { + binding.viewer.isUserInputEnabled = it ?: true + } + actions.actionEvent.observe(viewLifecycleOwner, Observer(::handle)) + } + + private fun handle(action: Pair?) { + when (action?.first) { + ViewerActions.SET_CURRENT_ITEM -> binding.viewer.currentItem = max(action.second as Int, 0) + ViewerActions.DISMISS -> onBackPressed() + ViewerActions.REMOVE_ITEMS -> viewModel.remove(adapter, action.second) { onBackPressed() } + } + } + + private val adapterListener by lazy { + object : ImageViewerAdapterListener { + override fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) { + TransitionStartHelper.start(this@ImageViewerDialogFragment, transformer.getView(initKey), viewHolder) + binding.background.changeToBackgroundColor(Config.VIEWER_BACKGROUND_COLOR) + userCallback.onInit(viewHolder, position) + } + + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + binding.background.updateBackgroundColor(fraction, Config.VIEWER_BACKGROUND_COLOR, Color.TRANSPARENT) + userCallback.onDrag(viewHolder, view, fraction) + } + + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + binding.background.changeToBackgroundColor(Config.VIEWER_BACKGROUND_COLOR) + userCallback.onRestore(viewHolder, view, fraction) + } + + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { + val startView = (view.getTag(R.id.viewer_adapter_item_key) as? Long?)?.let { transformer.getView(it) } + TransitionEndHelper.end(this@ImageViewerDialogFragment, startView, viewHolder) + binding.background.changeToBackgroundColor(Color.TRANSPARENT) + userCallback.onRelease(viewHolder, view) + } + } + } + + private val pagerCallback by lazy { + object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + userCallback.onPageScrollStateChanged(state) + } + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + userCallback.onPageScrolled(position, positionOffset, positionOffsetPixels) + } + + override fun onPageSelected(position: Int) { + val currentKey = viewModel.snapshot[position].id() + val holder = binding.viewer.findViewWithKeyTag(R.id.viewer_adapter_item_key, currentKey) + ?.getTag(R.id.viewer_adapter_item_holder) as? RecyclerView.ViewHolder? + ?: return + + if (submitPagingData) { + submitPagingData = false + viewerHandler.removeMessages(taskId) + viewerHandler.sendMessageDelayed( + Message.obtain(viewerHandler, taskId, position, 0, holder), + Config.VIEWER_FIRST_PAGE_SELECTED_DELAY + ) + return + } + viewerHandler.removeMessages(taskId) + Handler(Looper.getMainLooper()).post { + userCallback.onPageSelected(position, adapter.getPositionData(position), adapter.itemCount, holder) + } + } + } + } + + override fun showFailure(message: String?) { + super.showFailure(message) + Components.release() + } + + override fun onDestroyView() { + super.onDestroyView() + viewerHandler.removeMessages(taskId) + adapter.setListener(null) + binding.viewer.unregisterOnPageChangeCallback(pagerCallback) + binding.viewer.adapter = null + innerBinding = null + Components.release() + } + + override fun onBackPressed() { + if (TransitionStartHelper.transitionAnimating || TransitionEndHelper.transitionAnimating) return + + val currentKey = viewModel.snapshot[binding.viewer.currentItem].id() + binding.viewer.findViewWithKeyTag(R.id.viewer_adapter_item_key, currentKey)?.let { endView -> + val startView = transformer.getView(endView.getTag(R.id.viewer_adapter_item_key) as Long) + binding.background.changeToBackgroundColor(Color.TRANSPARENT) + + (endView.getTag(R.id.viewer_adapter_item_holder) as? RecyclerView.ViewHolder?)?.let { + TransitionEndHelper.end(this, startView, it) + userCallback.onRelease(it, endView) + } + } + } + + private val viewerHandler by lazy { + Handler(Looper.getMainLooper()) { + it.target.removeMessages(it.what) + userCallback.onPageSelected( + it.arg1, + adapter.getPositionData(it.arg1), + adapter.itemCount, + it.obj as RecyclerView.ViewHolder + ) + true + } + } + + open class Factory { + open fun build(): ImageViewerDialogFragment = ImageViewerDialogFragment() + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt new file mode 100644 index 0000000..c59ec3d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.widget.imageviewer + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.paging.PagingData +import com.remax.visualnovel.widget.imageviewer.adapter.ImageViewerAdapter +import com.remax.visualnovel.widget.imageviewer.adapter.Repository +import com.remax.visualnovel.widget.imageviewer.core.Photo + +@Suppress("UNCHECKED_CAST") +class ImageViewerViewModel : ViewModel() { + private val repository = Repository() + val snapshot: List get() = repository.snapshot + val pagingData: LiveData> = repository.pagingData + val viewerUserInputEnabled = MutableLiveData() + + fun setViewerUserInputEnabled(enable: Boolean) { + if (viewerUserInputEnabled.value != enable) viewerUserInputEnabled.value = enable + } + + fun remove(adapter: ImageViewerAdapter, item: Any?, emptyCallback: () -> Unit) { + val removed = (item as? List?) ?: return + repository.redirect(adapter, removed, emptyCallback) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt new file mode 100644 index 0000000..f7fc11e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt @@ -0,0 +1,7 @@ +package com.remax.visualnovel.widget.imageviewer + +object ViewerActions { + const val SET_CURRENT_ITEM = "setCurrentItem" + const val DISMISS = "dismiss" + const val REMOVE_ITEMS = "removeItems" +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt new file mode 100644 index 0000000..fe997f0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt @@ -0,0 +1,93 @@ +package com.remax.visualnovel.widget.imageviewer.adapter + +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.viewholders.PhotoViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.SubsamplingViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.UnknownViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.VideoViewHolder +import java.util.Objects + +class ImageViewerAdapter(initKey: Long) : PagingDataAdapter(diff) { + private var listener: ImageViewerAdapterListener? = null + private var key = initKey + + fun setListener(callback: ImageViewerAdapterListener?) { + listener = callback + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ItemType.PHOTO -> PhotoViewHolder(parent, callback) + ItemType.SUBSAMPLING -> SubsamplingViewHolder(parent, callback) + ItemType.VIDEO -> VideoViewHolder(parent, callback) + else -> UnknownViewHolder(View(parent.context)) + } + } + + fun getPositionData(position: Int) = provideItem(position) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = provideItem(position) + when (holder) { + is PhotoViewHolder -> item?.let { holder.bind(it, position) } + is SubsamplingViewHolder -> item?.let { holder.bind(it, position) } + is VideoViewHolder -> item?.let { holder.bind(it, position) } + } + if (item?.id() == key) { + listener?.onInit(holder, position) + key = NO_ID + } + } + + override fun getItemViewType(position: Int) = provideItem(position)?.itemType() ?: ItemType.UNKNOWN + private val callback: ImageViewerAdapterListener = object : ImageViewerAdapterListener { + override fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) { + listener?.onInit(viewHolder, position) + } + + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + listener?.onDrag(viewHolder, view, fraction) + } + + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { + listener?.onRelease(viewHolder, view) + } + + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + listener?.onRestore(viewHolder, view, fraction) + } + } + + private fun provideItem(position: Int) = try { + // Fatal Exception: java.util.ConcurrentModificationException + // IndexOutOfBoundsException Item count is zero, getItem() call is invalid + getItem(position) + } catch (e: Throwable) { + null + } +} + +private val diff + get() = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Photo, + newItem: Photo + ): Boolean { + return newItem.itemType() == oldItem.itemType() && newItem.id() == oldItem.id() + } + + override fun areContentsTheSame( + oldItem: Photo, + newItem: Photo + ): Boolean { + return newItem.itemType() == oldItem.itemType() && newItem.id() == oldItem.id() + && Objects.equals(newItem.extra(), oldItem.extra()) + } + } diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt new file mode 100644 index 0000000..90d4e21 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.widget.imageviewer.adapter + +import androidx.annotation.IntDef + +object ItemType { + const val UNKNOWN = -1 + const val PHOTO = 1 + const val SUBSAMPLING = 2 + const val VIDEO = 3 + + @Target(AnnotationTarget.TYPE) + @IntDef(PHOTO, SUBSAMPLING, VIDEO) + @Retention(AnnotationRetention.SOURCE) + annotation class Type +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt new file mode 100644 index 0000000..a2cb337 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt @@ -0,0 +1,83 @@ +package com.remax.visualnovel.widget.imageviewer.adapter + +import androidx.lifecycle.MutableLiveData +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.liveData +import com.remax.visualnovel.extension.resumeWithActive +import com.remax.visualnovel.widget.imageviewer.core.Components +import com.remax.visualnovel.widget.imageviewer.core.Photo +import kotlinx.coroutines.suspendCancellableCoroutine + +class Repository { + private val dataProvider by lazy { Components.requireDataProvider() } + private val dataList = MutableLiveData>() + internal val snapshot: List get() = dataList.value ?: listOf() + internal val pagingData = Pager(PagingConfig(1), null) { dataSource() }.liveData + + private fun dataSource() = object : PagingSource() { + override fun getRefreshKey(state: PagingState): Long? = null + override suspend fun load(params: LoadParams): LoadResult { + when (params) { + is LoadParams.Refresh -> { + val list: List = snapshot.ifEmpty { dataProvider.loadInitial() } + dataList.value = list + return LoadResult.Page(list, list.firstOrNull()?.id(), list.lastOrNull()?.id()) + } + is LoadParams.Append -> { + val list: List = suspendCancellableCoroutine { continuation -> + dataProvider.loadAfter(params.key) { + continuation.resumeWithActive(it) + } + continuation.invokeOnCancellation { + continuation.resumeWithActive(emptyList()) + } + } + dataList.value = snapshot.toMutableList().also { it.addAll(list) } + return LoadResult.Page(list, list.firstOrNull()?.id(), list.lastOrNull()?.id()) + } + is LoadParams.Prepend -> { + val list: List = suspendCancellableCoroutine { continuation -> + dataProvider.loadBefore(params.key) { + continuation.resumeWithActive(it) + } + continuation.invokeOnCancellation { + continuation.resumeWithActive(emptyList()) + } + } + dataList.value = snapshot.toMutableList().also { it.addAll(0, list) } + return LoadResult.Page(list, list.firstOrNull()?.id(), list.lastOrNull()?.id()) + } + } + } + } + + fun redirect(adapter: ImageViewerAdapter, exclude: List, emptyCallback: () -> Unit) { + synchronized(this) { + val list = snapshot.asSequence() + val _dataList = list.filter { + !exclude.contains(it) + }.toList() + if (_dataList.isEmpty()) { + return Unit.also { emptyCallback() } + } + dataList.value = _dataList + + //找到应该定位到的targetItem + val last = exclude.lastOrNull() + val lastIndex = snapshot.indexOf(last) + val first = exclude.firstOrNull() + val firstIndex = exclude.indexOf(first) + val targetIndex = if (lastIndex != snapshot.size - 1) { + lastIndex + 1 + } else { + firstIndex - 1 + } + val target = snapshot[targetIndex] + dataProvider.exclude(exclude, target) + adapter.refresh() + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt new file mode 100644 index 0000000..f46f960 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt @@ -0,0 +1,56 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import com.remax.visualnovel.widget.imageviewer.core.DataProvider +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.OverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Transformer +import com.remax.visualnovel.widget.imageviewer.core.VHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.ViewerCallback + +object Components { + private var initialize = false + val working get() = initialize + private var imageLoader: ImageLoader? = null + private var dataProvider: DataProvider? = null + private var transformer: Transformer? = null + private var vhCustomizer: VHCustomizer? = null + private var overlayCustomizer: OverlayCustomizer? = null + private var viewerCallback: ViewerCallback? = null + + fun initialize(imageLoader: ImageLoader, dataProvider: DataProvider, transformer: Transformer) { + if (initialize) throw IllegalStateException() + Components.imageLoader = imageLoader + Components.dataProvider = dataProvider + Components.transformer = transformer + initialize = true + } + + fun setVHCustomizer(vhCustomizer: VHCustomizer?) { + Components.vhCustomizer = vhCustomizer + } + + fun setViewerCallback(viewerCallback: ViewerCallback?) { + Components.viewerCallback = viewerCallback + } + + fun setOverlayCustomizer(overlayCustomizer: OverlayCustomizer?) { + Components.overlayCustomizer = overlayCustomizer + } + + fun requireImageLoader() = imageLoader ?: object : ImageLoader {} + fun requireDataProvider() = dataProvider ?: object : DataProvider {} + fun requireTransformer() = transformer ?: object : Transformer {} + fun requireVHCustomizer() = vhCustomizer ?: object : VHCustomizer {} + fun requireViewerCallback() = viewerCallback ?: object : ViewerCallback {} + fun requireOverlayCustomizer() = overlayCustomizer ?: object : OverlayCustomizer {} + + fun release() { + initialize = false + imageLoader = null + dataProvider = null + transformer = null + vhCustomizer = null + viewerCallback = null + overlayCustomizer = null + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt new file mode 100644 index 0000000..e6744f1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +interface DataProvider { + fun loadInitial(): List = emptyList() + fun loadAfter(key: Long, callback: (List) -> Unit) {} + fun loadBefore(key: Long, callback: (List) -> Unit) {} + fun exclude(exclude: List, target: Photo) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt new file mode 100644 index 0000000..7ca3e29 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt @@ -0,0 +1,12 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView2 + +interface ImageLoader { + fun load(view: ImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) {} + fun load(subsamplingView: SubsamplingScaleImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) {} + fun load(exoVideoView: ExoVideoView2, data: Photo, viewHolder: RecyclerView.ViewHolder) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt new file mode 100644 index 0000000..8db8296 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.view.View +import android.view.ViewGroup + +interface OverlayCustomizer { + fun provideView(parent: ViewGroup): View? = null +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt new file mode 100644 index 0000000..32bf375 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt @@ -0,0 +1,9 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType + +interface Photo { + fun id(): Long + fun itemType(): @ItemType.Type Int + fun extra(): Any = this +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt new file mode 100644 index 0000000..1944447 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt @@ -0,0 +1,40 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.os.Handler +import android.os.Looper + +import kotlin.math.min + +class SimpleDataProvider( + init: Photo, + list: List +) : DataProvider { + private var _init = init + private var _list = list + + val list = _list + + override fun loadInitial() = listOf(_init) + override fun loadAfter(key: Long, callback: (List) -> Unit) { + val idx = _list.indexOfFirst { it.id() == key } + val result: List = if (idx < 0) emptyList() + else _list.subList(idx + 1, _list.size) + Handler(Looper.getMainLooper()).post { + callback(result) + } + } + + override fun loadBefore(key: Long, callback: (List) -> Unit) { + val idx = _list.indexOfFirst { it.id() == key } + val result: List = if (idx < 0) emptyList() + else _list.subList(0, min(idx, _list.size)) + Handler(Looper.getMainLooper()).post { + callback(result) + } + } + + override fun exclude(exclude: List, target: Photo) { + _init = target + _list = _list.filter { !exclude.contains(it) } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt new file mode 100644 index 0000000..3d99b0c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.widget.ImageView + +interface Transformer { + fun getView(key: Long): ImageView? = null +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt new file mode 100644 index 0000000..0177718 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import androidx.recyclerview.widget.RecyclerView + +interface VHCustomizer { + fun initialize(type: Int, viewHolder: RecyclerView.ViewHolder) {} + fun bind(type: Int, data: Photo, position: Int, viewHolder: RecyclerView.ViewHolder) {} +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt new file mode 100644 index 0000000..846f35a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener + +interface ViewerCallback : ImageViewerAdapterListener { + override fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) {} + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) {} + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) {} + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) {} + fun onPageScrollStateChanged(state: Int) {} + fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} + fun onPageSelected(position: Int, item: Photo?,totalCount: Int, viewHolder: RecyclerView.ViewHolder) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt new file mode 100644 index 0000000..5dd830f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt @@ -0,0 +1,20 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.graphics.Color +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView + +object Config { + var DEBUG: Boolean = true + var OFFSCREEN_PAGE_LIMIT: Int = 1 + var VIEWER_ORIENTATION: Int = ViewPager2.ORIENTATION_HORIZONTAL + var VIEWER_BACKGROUND_COLOR: Int = Color.BLACK + var DURATION_TRANSITION: Long = 250L + var DURATION_BG: Long = 150L + var SWIPE_DISMISS: Boolean = true + var SWIPE_TOUCH_SLOP = 4f + var DISMISS_FRACTION: Float = 0.12f + var TRANSITION_OFFSET_Y = 0 + var VIEWER_FIRST_PAGE_SELECTED_DELAY = 300L + var VIDEO_SCALE_TYPE = ExoVideoView.SCALE_TYPE_FIT_CENTER +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt new file mode 100644 index 0000000..505a8eb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.View +import android.view.ViewGroup +import androidx.core.view.forEach + +internal fun ViewGroup.findViewWithKeyTag(key: Int, tag: Any): View? { + forEach { + if (it.getTag(key) == tag) return it + if (it is ViewGroup) { + val result = it.findViewWithKeyTag(key, tag) + if (result != null) return result + } + } + return null +} + +internal val View.activity: Activity? + get() = getActivity(context) + +// https://stackoverflow.com/questions/9273218/is-it-always-safe-to-cast-context-to-activity-within-view/45364110 +private fun getActivity(context: Context?): Activity? { + if (context == null) return null + if (context is Activity) return context + if (context is ContextWrapper) return getActivity(context.baseContext) + return null +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt new file mode 100644 index 0000000..5529993 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt @@ -0,0 +1,197 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView +import androidx.core.view.ViewCompat +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.ChangeBounds +import androidx.transition.ChangeImageTransform +import androidx.transition.ChangeTransform +import androidx.transition.Transition +import androidx.transition.TransitionListenerAdapter +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.imageviewer.viewholders.PhotoViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.SubsamplingViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.VideoViewHolder +import kotlin.math.max + +object TransitionEndHelper { + val transitionAnimating get() = animating + private var animating = false + + fun end(fragment: DialogFragment, startView: View?, holder: RecyclerView.ViewHolder) { + beforeTransition(startView, holder) + val doTransition = { + TransitionManager.beginDelayedTransition(holder.itemView as ViewGroup, transitionSet().also { + it.addListener(object : TransitionListenerAdapter() { + override fun onTransitionStart(transition: Transition) { + animating = true + } + + override fun onTransitionEnd(transition: Transition) { + if (!animating) return + animating = false + fragment.dismissAllowingStateLoss() + } + }) + }) + transition(startView, holder) + } + holder.itemView.post(doTransition) + + fragment.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + fragment.lifecycle.removeObserver(this) + animating = false + holder.itemView.removeCallbacks(doTransition) + TransitionManager.endTransitions(holder.itemView as ViewGroup) + } + } + }) + } + + private fun beforeTransition(startView: View?, holder: RecyclerView.ViewHolder) { + when (holder) { + is VideoViewHolder -> { + holder.binding.imageView.translationX = holder.binding.videoView.translationX + holder.binding.imageView.translationY = holder.binding.videoView.translationY + holder.binding.imageView.scaleX = holder.binding.videoView.scaleX + holder.binding.imageView.scaleY = holder.binding.videoView.scaleY + holder.binding.imageView.visibility = View.VISIBLE + holder.binding.videoView.visibility = View.GONE + } + } + } + + private fun transition(startView: View?, holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + holder.binding.photoView.scaleType = (startView as? ImageView?)?.scaleType + ?: ImageView.ScaleType.FIT_CENTER + holder.binding.photoView.translationX = 0f + holder.binding.photoView.translationY = 0f + holder.binding.photoView.scaleX = if (startView != null) 1f else 2f + holder.binding.photoView.scaleY = if (startView != null) 1f else 2f + // holder.photoView.alpha = startView?.alpha ?: 0f + fade(holder, startView) + holder.binding.photoView.layoutParams = holder.binding.photoView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.translationX = 0f + holder.binding.subsamplingView.translationY = 0f + holder.binding.subsamplingView.scaleX = 2f + holder.binding.subsamplingView.scaleY = 2f + // holder.photoView.alpha = startView?.alpha ?: 0f + fade(holder) // https://github.com/davemorrissey/subsampling-scale-image-view/issues/313 + holder.binding.subsamplingView.layoutParams = holder.binding.subsamplingView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is VideoViewHolder -> { + holder.binding.imageView.translationX = 0f + holder.binding.imageView.translationY = 0f + holder.binding.imageView.scaleX = if (startView != null) 1f else 2f + holder.binding.imageView.scaleY = if (startView != null) 1f else 2f + // holder.photoView.alpha = startView?.alpha ?: 0f + fade(holder, startView) + holder.binding.videoView.pause() + holder.binding.imageView.layoutParams = holder.binding.imageView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + } + } + + private fun transitionSet(): Transition { + return TransitionSet().apply { + addTransition(ChangeBounds()) + addTransition(ChangeImageTransform()) + addTransition(ChangeTransform()) + // addTransition(Fade()) + duration = Config.DURATION_TRANSITION + interpolator = DecelerateInterpolator() + } + } + + private fun fade(holder: RecyclerView.ViewHolder, startView: View? = null) { + when (holder) { + is PhotoViewHolder -> { + if (startView != null) { + holder.binding.photoView.animate() + .setDuration(0) + .setStartDelay(max(Config.DURATION_TRANSITION - 20, 0)) + .alpha(0f).start() + } else { + holder.binding.photoView.animate().setDuration(Config.DURATION_TRANSITION) + .alpha(0f).start() + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.animate().setDuration(Config.DURATION_TRANSITION) + .alpha(0f).start() + } + + is VideoViewHolder -> { + if (startView != null) { + holder.binding.imageView.animate() + .setDuration(0) + .setStartDelay(max(Config.DURATION_TRANSITION - 20, 0)) + .alpha(0f).start() + } else { + holder.binding.imageView.animate().setDuration(Config.DURATION_TRANSITION) + .alpha(0f).start() + } + } + } + } + + private fun getLocationOnScreen(startView: View?, location: IntArray) { + startView?.getLocationOnScreen(location) + + if (location[0] == 0) { + location[0] = (startView?.getTag(R.id.viewer_start_view_location_0) as? Int) ?: 0 + } + if (location[1] == 0) { + location[1] = (startView?.getTag(R.id.viewer_start_view_location_1) as? Int) ?: 0 + } + + if (startView?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) { + location[0] = startView.context.resources.displayMetrics.widthPixels - location[0] - startView.width + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt new file mode 100644 index 0000000..d22fc5b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt @@ -0,0 +1,178 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView +import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.ChangeBounds +import androidx.transition.ChangeImageTransform +import androidx.transition.Transition +import androidx.transition.TransitionListenerAdapter +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.viewholders.PhotoViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.SubsamplingViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.VideoViewHolder + +object TransitionStartHelper { + val transitionAnimating get() = animating + private var animating = false + + fun start(owner: LifecycleOwner, startView: View?, holder: RecyclerView.ViewHolder) { + beforeTransition(startView, holder) + val doTransition = { + TransitionManager.beginDelayedTransition(holder.itemView as ViewGroup, transitionSet().also { + it.addListener(object : TransitionListenerAdapter() { + override fun onTransitionStart(transition: Transition) { + animating = true + } + + override fun onTransitionEnd(transition: Transition) { + if (!animating) return + animating = false + afterTransition(holder) + } + }) + }) + transition(holder) + } + holder.itemView.postDelayed(doTransition, 50) + + owner.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + owner.lifecycle.removeObserver(this) + animating = false + holder.itemView.removeCallbacks(doTransition) + TransitionManager.endTransitions(holder.itemView as ViewGroup) + } + } + }) + } + + private fun beforeTransition(startView: View?, holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + holder.binding.photoView.scaleType = (startView as? ImageView?)?.scaleType + ?: ImageView.ScaleType.FIT_CENTER + holder.binding.photoView.layoutParams = holder.binding.photoView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.layoutParams = holder.binding.subsamplingView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is VideoViewHolder -> { + holder.binding.imageView.layoutParams = holder.binding.imageView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + } + } + + private fun transition(holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + holder.binding.photoView.scaleType = ImageView.ScaleType.FIT_CENTER + holder.binding.photoView.layoutParams = holder.binding.photoView.layoutParams.apply { + width = MATCH_PARENT + height = MATCH_PARENT + if (this is ViewGroup.MarginLayoutParams) { + marginStart = 0 + topMargin = 0 + } + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.layoutParams = holder.binding.subsamplingView.layoutParams.apply { + width = MATCH_PARENT + height = MATCH_PARENT + if (this is ViewGroup.MarginLayoutParams) { + marginStart = 0 + topMargin = 0 + } + } + } + + is VideoViewHolder -> { + holder.binding.imageView.layoutParams = holder.binding.imageView.layoutParams.apply { + width = MATCH_PARENT + height = MATCH_PARENT + if (this is ViewGroup.MarginLayoutParams) { + marginStart = 0 + topMargin = 0 + } + } + } + } + } + + private fun transitionSet(): Transition { + return TransitionSet().apply { + addTransition(ChangeBounds()) + addTransition(ChangeImageTransform()) + // https://github.com/davemorrissey/subsampling-scale-image-view/issues/313 + duration = Config.DURATION_TRANSITION + interpolator = DecelerateInterpolator() + } + } + + private fun getLocationOnScreen(startView: View?, location: IntArray) { + startView?.getLocationOnScreen(location) + + if (location[0] == 0) { + location[0] = (startView?.getTag(R.id.viewer_start_view_location_0) as? Int) ?: 0 + } + if (location[1] == 0) { + location[1] = (startView?.getTag(R.id.viewer_start_view_location_1) as? Int) ?: 0 + } + + if (startView?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) { + location[0] = startView.context.resources.displayMetrics.widthPixels - location[0] - startView.width + } + } + + private fun afterTransition(holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + val photo = holder.binding.photoView.getTag(R.id.viewer_adapter_item_data) as Photo + requireImageLoader().load(holder.binding.photoView, photo, holder) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt new file mode 100644 index 0000000..87be394 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt @@ -0,0 +1,17 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.view.View +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.remax.visualnovel.widget.imageviewer.ImageViewerDialogFragment + +internal object ViewModelUtils { + inline fun provideViewModel(view: View): T? { + return (view.activity as? FragmentActivity?) + ?.supportFragmentManager + ?.fragments + ?.find { it is ImageViewerDialogFragment } + ?.let { ViewModelProvider(it).get(T::class.java) } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt new file mode 100644 index 0000000..cd00844 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt @@ -0,0 +1,225 @@ +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.lottie.LottieAnimationView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.target.ImageViewTarget +import com.bumptech.glide.request.transition.Transition +import com.remax.visualnovel.R +import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData +import com.remax.visualnovel.entity.model.MyImgData +import com.remax.visualnovel.entity.response.Album +import com.remax.visualnovel.entity.response.AppearanceImage +import com.remax.visualnovel.entity.response.ChatBackground +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.dialoglib.ScreenUtils +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.PhotoView2 +import timber.log.Timber + +class MyImageLoader : ImageLoader { + + private val screenWidth by lazy { + ScreenUtils.getScreenWidth() + } + + override fun load(view: ImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) { + var isLock = false + val loadUrl = when (data) { + is Album -> { + isLock = data.imgUrl == null + data.imgUrl ?: data.img3 ?: return + } + + is AppearanceImage -> { + data.imageUrl + } + + is ChatBackground -> { + data.imgUrl + } + + is CustomAlbumData -> { + data.url + } + + else -> { + (data as? MyImgData?)?.url ?: return + } + } + loadImg(viewHolder.itemView, loadUrl, isLock) + } + + fun loadImg( + itemView: View, + url: String?, + isLock: Boolean + ) { + itemView.run { + val view = findViewById(R.id.photoView) + val mask = findViewById(R.id.mask) + val failIcon = findViewById(R.id.failIcon) + val lockViewGroup = findViewById(R.id.lockViewGroup) + val failTv = findViewById(R.id.failText) + val loadingView = findViewById(R.id.loadingView) + + setOnClick(mask) { + when (this) { + mask -> { + if (failIcon.isVisible) { + loadImg(itemView, url, isLock) + } + } + } + } + + Timber.d("大图加载 url: $url") +// val width = view.measuredWidth +// val height = view.measuredHeight +// Timber.d("大图加载 图片控件的宽高 $width $height ") +// val loadUrl = if (!isLock && width == screenWidth && height > 0) url.toS3Url( +// view.measuredWidth, +// view.measuredHeight, +// false +// ) else url +// Timber.d("大图加载裁剪 loadUrl: $loadUrl") + Glide.with(view).load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .downsample(DownsampleStrategy.AT_LEAST) + .into(object : ImageViewTarget(view) { + override fun setResource(resource: Drawable?) { + + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + super.onResourceReady(resource, transition) + mask?.isVisible = false + failIcon?.isVisible = false + failTv?.isVisible = false + lockViewGroup?.isVisible = isLock + view.isVisible = true + val viewWidth = view.measuredWidth + val viewHeight = view.measuredHeight + val result = if (viewWidth == screenWidth) + cropDrawableFromTopCenter(view.context, resource, viewWidth, viewHeight) + else resource + view.setImageDrawable(result) + } + + override fun onLoadStarted(placeholder: Drawable?) { + super.onLoadStarted(placeholder) + mask?.isVisible = true + failIcon?.isVisible = false + failTv?.isVisible = false + view.isInvisible = true + lockViewGroup?.isVisible = false + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + super.onLoadFailed(errorDrawable) + mask?.isVisible = true + failIcon?.isVisible = true + failTv?.isVisible = true + view.isInvisible = true + lockViewGroup?.isVisible = false + } + }) + } + } + + + private fun cropDrawableFromTopCenter( + context: Context, + drawable: Drawable, + targetWidth: Int, + targetHeight: Int + ): Drawable { + // 将 Drawable 转换为 Bitmap + val bitmap = drawableToBitmap(drawable) ?: return drawable + Timber.d("大图加载 bitmap的宽高 ${bitmap.width} ${bitmap.height}") + Timber.d("大图加载 裁剪目标的宽高 $targetWidth $targetHeight") + + // 按比例调整目标尺寸 + val (adjustedWidth, adjustedHeight) = adjustTargetSize( + bitmap.width, + bitmap.height, + targetWidth, + targetHeight + ) + + Timber.d("大图加载 按比例调整 $adjustedWidth $adjustedHeight") + + // 计算裁剪的起始点(顶部中间) + val startX = (bitmap.width - adjustedWidth) / 2 + val startY = 0 // 从顶部开始 + + // 确保裁剪区域有效 + if (startX < 0 || adjustedWidth > bitmap.width || adjustedHeight > bitmap.height) { + return drawable // 或者抛出异常,视需求而定 + } + + // 裁剪 Bitmap + val croppedBitmap = Bitmap.createBitmap(bitmap, startX, startY, adjustedWidth, adjustedHeight) + + // 将裁剪后的 Bitmap 转换回 Drawable + return croppedBitmap.toDrawable(context.resources) + } + + // 辅助函数:将 Drawable 转换为 Bitmap + private fun drawableToBitmap(drawable: Drawable): Bitmap? { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + + // 如果 Drawable 不是 BitmapDrawable,创建一个 Bitmap 并绘制 + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + + // 辅助函数:按比例调整目标尺寸 + private fun adjustTargetSize( + bitmapWidth: Int, + bitmapHeight: Int, + targetWidth: Int, + targetHeight: Int + ): Pair { + // 如果目标尺寸小于等于 Bitmap 尺寸,直接返回 + if (targetWidth <= bitmapWidth && targetHeight <= bitmapHeight) { + return Pair(targetWidth, targetHeight) + } + + // 计算宽高比例 + val widthRatio = bitmapWidth.toFloat() / targetWidth + val heightRatio = bitmapHeight.toFloat() / targetHeight + + // 取较小的比例以确保裁剪区域不超过 Bitmap 尺寸 + val scaleRatio = minOf(widthRatio, heightRatio) + + // 按比例调整目标宽高 + val adjustedWidth = (targetWidth * scaleRatio).toInt() + val adjustedHeight = (targetHeight * scaleRatio).toInt() + + return Pair(adjustedWidth, adjustedHeight) + + } +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt new file mode 100644 index 0000000..ff79271 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.widget.ImageView +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.remax.visualnovel.extension.findActivityContext +import com.remax.visualnovel.widget.imageviewer.core.Transformer +import dagger.hilt.android.internal.ThreadUtil +import timber.log.Timber + +class SimpleTransformer : Transformer { + override fun getView(key: Long): ImageView? = ViewerTransitionHelper.provide(key) +} + +/** + * 维护Transition过渡动画的缩略图和大图之间的映射关系. + */ +object ViewerTransitionHelper { + + private val transition = HashMap() + + fun put(imageView: ImageView?, photoId: Long = imageView.hashCode().toLong()) { + require(ThreadUtil.isMainThread()) + (imageView?.context?.findActivityContext() as? LifecycleOwner)?.lifecycle?.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + transition.remove(imageView) + Timber.d("transitionHashMap onLifecycleObserverDestroy remove :${transition.size}") + } + }) + imageView?.let { + transition[imageView] = photoId + Timber.d("transitionHashMap put :${transition.size}") + } + } + + fun provide(photoId: Long): ImageView? { + transition.keys.forEach { + if (transition[it] == photoId) + return it + } + return null + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt new file mode 100644 index 0000000..3bd7ecc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt @@ -0,0 +1,555 @@ +/* +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.animation.Animator +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.remax.visualnovel.R +import com.remax.visualnovel.app.widget.tips.TipsMoreWindow +import com.remax.visualnovel.databinding.LayoutIndicatorBinding +import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData +import com.remax.visualnovel.entity.request.ChatAlbum +import com.remax.visualnovel.entity.response.Album +import com.remax.visualnovel.entity.response.AppearanceImage +import com.remax.visualnovel.entity.response.ChatBackground +import com.remax.visualnovel.entity.response.Wallet +import com.remax.visualnovel.extension.changeLikedStatus +import com.remax.visualnovel.extension.formatPrice +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.showDoubleBtnDialog +import com.remax.visualnovel.extension.showSingleBtnDialog +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.ui.chat.ChatActivity +import com.remax.visualnovel.ui.chat.message.setting.background.ChatBackgroundActivity +import com.remax.visualnovel.ui.main.MainActivity +import com.remax.visualnovel.ui.main.create.CreateActivity +import com.remax.visualnovel.ui.main.create.image.ImageGenerateUtil +import com.remax.visualnovel.ui.main.foryou.ForYouFragment +import com.remax.visualnovel.ui.profile.CharacterProfileActivity +import com.remax.visualnovel.ui.profile.album.AlbumFragment +import com.remax.visualnovel.ui.profile.album.create.AlbumCreateActivity +import com.remax.visualnovel.ui.profile.album.um.UnlockMethodActivity +import com.remax.visualnovel.utils.EpalUtils +import com.remax.visualnovel.utils.StatusBarUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.imageviewer.ImageViewerActionViewModel +import com.remax.visualnovel.widget.imageviewer.ImageViewerBuilder +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.OverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.core.VHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.ViewerCallback +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.widgets.PhotoView2 +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView +import com.remax.visualnovel.widget.ui.lock.getLockLabel +import com.pengxr.modular.eventbus.generated.events.EventDefineOfAlbumEvents +import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents +import io.reactivex.disposables.Disposable +import timber.log.Timber + +*/ +/** + * viewer 自定义业务&UI + *//* + +class MyViewerCustomizer : DefaultLifecycleObserver, VHCustomizer, OverlayCustomizer, ViewerCallback { + private var activity: FragmentActivity? = null + private var viewerActions: ImageViewerActionViewModel? = null + private var videoTask: Disposable? = null + private var lastVideoVH: RecyclerView.ViewHolder? = null + private var indicatorBinding: LayoutIndicatorBinding? = null + private var currentPosition = -1 + private var currData: Photo? = null + private var imageLoader: ImageLoader? = null + + private val albumLikeFailedObserver = androidx.lifecycle.Observer { + it?.let { + changeAlbumStatus(it, true) + } + } + + private val walletObserver = androidx.lifecycle.Observer { + changeWallet() + } + + */ +/** + * 对viewer进行自定义封装. 添加自定义指示器.video绑定.图片说明等自定义配置 + *//* + + fun process(activity: FragmentActivity, builder: ImageViewerBuilder) { + this.activity = activity + viewerActions = ViewModelProvider(activity)[ImageViewerActionViewModel::class.java] + activity.lifecycle.addObserver(this) + builder.setVHCustomizer(this) + builder.setOverlayCustomizer(this) + builder.setViewerCallback(this) + imageLoader = builder.imageLoader + + EventDefineOfAlbumEvents.albumLikeFailed().observe(activity, albumLikeFailedObserver) + EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().observe(activity, walletObserver) + } + + override fun initialize(type: Int, viewHolder: RecyclerView.ViewHolder) { + (viewHolder.itemView as? ViewGroup?)?.let { + it.addView(LayoutInflater.from(it.context).inflate(R.layout.item_photo_custom_layout, it, false)) + } + + when (type) { + ItemType.SUBSAMPLING -> { + viewHolder.itemView.findViewById(R.id.subsamplingView)?.run { + setOnClickListener { + viewerActions?.dismiss() + } + } + } + + ItemType.PHOTO -> { + viewHolder.itemView.run { + findViewById(R.id.photoView)?.run { + maximumScale = 8.0f +// postDelayed({ +// scale = 2.0f +// setScale(2.0f, false) +// }, 1000) + + setOnClickListener { viewerActions?.dismiss() } + } + } + } + + ItemType.VIDEO -> { + + } + } + } + + override fun bind(type: Int, data: Photo, position: Int, viewHolder: RecyclerView.ViewHolder) { + + } + + override fun provideView(parent: ViewGroup): View { + indicatorBinding = LayoutIndicatorBinding.inflate(LayoutInflater.from(parent.context), parent, false) + indicatorBinding?.run { + activity?.run { + indicatorLayout.setMargin(topMargin = StatusBarUtils.statusBarHeight) + val bottomMargin = (maxOf(StatusBarUtils.getNavBarHeight(), 20.dp) + 16.dp) + listOf( + albumLikeLayout, + previewSetView, + selectLayout, + albumLikeLayout, + chatBgSetLayout, + chatBgSelect + ).forEach { + it.setMargin(bottomMargin = bottomMargin) + } + } + lockViewGroup.setMyBalance() + + setOnClick(albumLikeLayout, navBack) { + when (this) { + navBack -> { + viewerActions?.dismiss() + } + + albumLikeLayout -> { + (currData as? Album)?.let { data -> + LoginManager.checkLogin { + EventDefineOfAlbumEvents.albumLike().post(data) + changeAlbumStatus(data) + } + } + } + } + } + } + return indicatorBinding!!.root + } + + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { +// viewHolder.itemView.findViewById(R.id.customizeDecor)?.findViewById(R.id.lockViewGroup)?.alpha = 0f + } + + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { +// viewHolder.itemView.findViewById(R.id.customizeDecor)?.findViewById(R.id.lockViewGroup)?.alpha = 1f + } + + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { + viewHolder.itemView.findViewById(R.id.customizeDecor) + ?.animate()?.setDuration(Config.DURATION_TRANSITION)?.alpha(0f)?.start() + indicatorBinding?.indicatorDecor?.animate()?.setDuration(Config.DURATION_TRANSITION)?.alpha(0f)?.start() + release() + } + + @SuppressLint("SetTextI18n") + override fun onPageSelected(position: Int, item: Photo?, totalCount: Int, viewHolder: RecyclerView.ViewHolder) { + currData = item + + fun setTotalIndicator(total: Int) { + Timber.d("Image preview totalCount:$total") + indicatorBinding?.indicator?.text = (position + 1).toString() + "/" + total + indicatorBinding?.indicator?.isVisible = total > 1 + } + + when (item) { + //看相册 + is Album -> { + if (item.userId == null) { + item.userId = (activity as? CharacterProfileActivity)?.characterProfileViewModel?.character?.userId + } + val isMyself = LoginManager.isMyself(item.userId) + indicatorBinding?.also { + it.navMore.isVisible = isMyself + setOnClick(it.navMore) { + TipsMoreWindow().build( + context, + listOf(TipsMoreWindow.TipsMoreUIData(R.string.delete, R.string.icon_delete)), + clickCallback = { + (currData as? Album)?.let { data -> + activity?.run { + if (data.isDefault == true) { + showSingleBtnDialog(text = getString(R.string.dialog_delete_warning_default)) + } else { + this.showDoubleBtnDialog( + text = getString(R.string.dialog_delete_warning), + topBtnClick = { + val target = listOf(data) + viewerActions?.remove(target) + (activity as? CharacterProfileActivity)?.let { act -> + (act.binding.viewPager.adapter as? FragmentStateAdapter)?.let { adapter -> + (adapter.createFragment(1) as? AlbumFragment)?.deleteAlbum( + data + ) + } + } + }, + topBtnText = getString(R.string.delete) + ) + } + + } + } + }).showAsDropDown(it.navMore, 0, 8.dp) + + } + + //自己看自己的可修改 + with(it.previewSetView) { + isVisible = isMyself + if (isMyself) { + setBlur() + setUI(item) + (activity as? CharacterProfileActivity)?.let { act -> + setCallback( + { + if ((item.unlockPrice ?: 0L) > 0) { + act.showDoubleBtnDialog( + text = act.getString(R.string.dialog_set_default_warning), + topBtnClick = { + act.setDefaultAlbum(item) { + it.lockView.isVisible = false + setUI(item) + } + }, + topBtnText = act.getString(R.string.confirm) + ) + } else { + act.setDefaultAlbum(item) { + setUI(item) + } + } + } + ) { + UnlockMethodActivity.start(item, act) { unlockPrice -> + item.unlockPrice = unlockPrice + act.setAlbumPrice(item) + setUI(item) + } + } + } + } + } + + + //先关闭点赞动画 + if (it.albumLikeLottie.isAnimating) { + it.albumLikeLottie.cancelAnimation() + } + it.albumLikeLottie.isVisible = false + it.albumLikeIcon.changeLikedStatus(item.isLike()) + it.albumLikeNum.text = EpalUtils.formatNumberAutoSize(item.likedCount) + it.lockView.setLockLabel(item.userId.getLockLabel()) + + it.lockViewGroup.isVisible = false + it.lockView.isVisible = false + it.albumLikeLayout.isVisible = false + + when { + //公开图片 + item.isOpen() -> { + it.albumLikeLayout.isVisible = !isMyself + } + //私密图片 + else -> { + //解锁可看可点赞 自己不能给自己的点 + it.albumLikeLayout.isVisible = item.isUnLock() && !isMyself + it.lockView.isVisible = item.isUnLock() + //未解锁需要给钱 + it.lockViewGroup.isVisible = !item.isUnLock() + it.lockViewGroup.setPreviewUnlockInfo(item.unlockPrice ?: 0) { + //解锁当前这张照片 + when (activity) { + is CharacterProfileActivity -> { + (activity as? CharacterProfileActivity)?.let { act -> + (act.binding.viewPager.adapter as? FragmentStateAdapter)?.let { adapter -> + (adapter.createFragment(1) as? AlbumFragment)?.unlockAlbumImage( + item.albumId, + item.unlockPrice + ) { + it.lockViewGroup.isVisible = false + it.lockView.isVisible = true + it.albumLikeLayout.isVisible = true + (imageLoader as? MyImageLoader)?.loadImg( + viewHolder.itemView, + item.img3, + false + ) + } + } + } + } + + is MainActivity -> { + (activity as? MainActivity)?.let { act -> + (act.binding.viewPager2.adapter as? FragmentStateAdapter)?.let { adapter -> + (adapter.createFragment(0) as? ForYouFragment)?.unlockAlbumImage( + ChatAlbum(item.aiId ?: "", item.albumId, item.unlockPrice) + ) { + it.lockViewGroup.isVisible = false + it.lockView.isVisible = true + it.albumLikeLayout.isVisible = true + (imageLoader as? MyImageLoader)?.loadImg( + viewHolder.itemView, + item.img3, + false + ) + } + } + } + } + } + } + } + } + } + } + + //AI创作选择 + is AppearanceImage -> { + indicatorBinding?.also { + when { + activity is AlbumCreateActivity && ((activity as? AlbumCreateActivity)?.generateScene == ImageGenerateUtil.ALBUM) -> { + it.albumBottomLayout.isVisible = true + it.albumCheckBox.viewChecked(item.select) + fun setPrice() { + it.albumPriceView.setPrice(formatPrice(item.unlockPrice)) + it.albumPriceView.isVisible = item.unlockPrice > 0 + it.albumFree.isVisible = !it.albumPriceView.isVisible + } + setPrice() + setOnClick(it.albumPrice, it.albumSelectLayout) { + when (this) { + it.albumPrice -> { + (activity as? AlbumCreateActivity)?.let { act -> + act.imageGenerateUtil.setPrice(item, act) { + setPrice() + } + } + } + */ +/** + * 相册单选 + *//* + + it.albumSelectLayout -> { + if (!item.select) { + it.albumCheckBox.viewChecked(true) + (activity as? AlbumCreateActivity)?.imageGenerateUtil?.setSelect(item) + } + } + } + } + } + + */ +/** + * 其他是单选 + *//* + + else -> { + it.selectLayout.isVisible = true + it.checkBox.viewChecked(item.select) + setOnClick(it.selectLayout) { + if (!item.select) { + it.checkBox.viewChecked(true) + it.checkBox.post { + //创建/编辑AI + (activity as? CreateActivity)?.imageGenerateUtil?.setSelect(item) + //IM聊天创作背景 + (activity as? AlbumCreateActivity)?.imageGenerateUtil?.setSelect(item) + } + } + } + } + } + + + } + } + + //聊天chat背景图 + is ChatBackground -> { + indicatorBinding?.also { + it.navMore.isVisible = !item.isDefault + it.chatBgSelect.isVisible = true + it.chatBgSelect.isEnabled = item.imgUrl != (activity as? ChatBackgroundActivity)?.backgroundImg + setOnClick(it.chatBgSelect, it.navMore) { + when (this) { + it.chatBgSelect -> { + (activity as? ChatBackgroundActivity)?.let { act -> + act.setBackground(item) { + viewerActions?.dismiss() + } + } + } + + it.navMore -> { + TipsMoreWindow().build( + context, + listOf(TipsMoreWindow.TipsMoreUIData(R.string.delete, R.string.icon_delete)), + clickCallback = { + (activity as? ChatBackgroundActivity)?.let { act -> + act.removeBackground(item) { + val target = listOf(item) + viewerActions?.remove(target) + } + } + }).showAsDropDown(it.navMore, 0, 8.dp) + + } + } + + } + } + } + + //IM列表图片 + is CustomAlbumData -> { + indicatorBinding?.also { + it.lockViewGroup.isVisible = item.unlockPrice != 0L + it.lockViewGroup.setPreviewUnlockInfo(item.unlockPrice) { + //解锁当前这张照片 + (activity as? ChatActivity)?.let { act -> + act.unlockAlbumImage(item.albumId, item.unlockPrice, item.messageServerId) { album -> + it.lockViewGroup.isVisible = false + item.unlockPrice = 0L + item.url = album?.img3 ?: "" + (imageLoader as? MyImageLoader)?.loadImg(viewHolder.itemView, item.url, false) + } + } + } + } + } + } + + currentPosition = position + setTotalIndicator(totalCount) + } + + override fun onResume(owner: LifecycleOwner) { + lastVideoVH?.itemView?.findViewById(R.id.videoView)?.resume() + } + + override fun onPause(owner: LifecycleOwner) { + lastVideoVH?.itemView?.findViewById(R.id.videoView)?.pause() + } + + override fun onDestroy(owner: LifecycleOwner) { + lastVideoVH?.itemView?.findViewById(R.id.videoView)?.release() + videoTask?.dispose() + videoTask = null + } + + private fun changeWallet() { + indicatorBinding?.lockViewGroup?.setMyBalance() + } + + private fun changeAlbumStatus(data: Album, fromFailed: Boolean = false) { + indicatorBinding?.run { + if (data.isLike()) { + if (fromFailed) { + albumLikeLottie.isVisible = false + albumLikeIcon.changeLikedStatus(true) + } else { + albumLikeIcon.isInvisible = true + albumLikeLottie.run { + isVisible = true + progress = 0f + addAnimatorListener(object : Animator.AnimatorListener { + override fun onAnimationStart(p0: Animator) { + isEnabled = false + } + + override fun onAnimationEnd(p0: Animator) { + isEnabled = true + progress = 1f + } + + override fun onAnimationCancel(p0: Animator) { + + } + + override fun onAnimationRepeat(p0: Animator) { + + } + }) + playAnimation() + } + } + } else { + albumLikeLottie.isVisible = false + albumLikeIcon.changeLikedStatus(false) + } + albumLikeNum.text = EpalUtils.formatNumberAutoSize(data.likedCount) + } + } + + private fun release() { + activity?.lifecycle?.removeObserver(this) + activity = null + videoTask?.dispose() + videoTask = null + lastVideoVH = null + indicatorBinding = null + EventDefineOfAlbumEvents.albumLikeFailed().removeObserver(albumLikeFailedObserver) + EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().removeObserver(walletObserver) + } + +} + + +*/ diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt new file mode 100644 index 0000000..5733509 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt @@ -0,0 +1,61 @@ +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.R +import com.remax.visualnovel.entity.model.MyImgData +import com.remax.visualnovel.widget.imageviewer.ImageViewerBuilder +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.core.SimpleDataProvider +import com.remax.visualnovel.widget.imageviewer.utils.Config + + +/** + * viewer的自定义初始化方案 + */ +object ViewerHelper { + fun openViewer( + context: FragmentActivity, + datas: List, + currPosition: Int = 0 + ) { + if (datas.isNotEmpty() && currPosition < datas.size) { + val newList = arrayListOf().apply { addAll(datas) } + provideImageViewerBuilder(context, newList[currPosition], newList).show() + } + } + + fun openViewerOne(context: FragmentActivity, imageView: View?, imageUrl: String?) { + openViewerOne(context, MyImgData(imageView?.hashCode()?.toLong() ?: 0L, imageUrl)) + } + + fun openViewerOne(context: FragmentActivity, data: Photo) { + val datas = arrayListOf(data) + provideImageViewerBuilder(context, datas[0], datas).show() + } + + private fun provideImageViewerBuilder( + context: FragmentActivity, + clickedData: Photo, + myImgDatas: List + ): ImageViewerBuilder { + // viewer 构造的基本元素 + val builder = ImageViewerBuilder( + context = context, + dataProvider = SimpleDataProvider(clickedData, myImgDatas), + imageLoader = MyImageLoader(), + transformer = SimpleTransformer() + ) +// Config.TRANSITION_OFFSET_Y = StatusBarUtils.statusBarHeight + Config.VIEWER_BACKGROUND_COLOR = ContextCompat.getColor(context, R.color.black) + Config.DEBUG = BuildConfig.DEBUG + + //MyViewerCustomizer().process(context, builder) // 添加自定义业务逻辑和UI处理 + + return builder + } +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt new file mode 100644 index 0000000..054c92a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ItemImageviewerPhotoBinding +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Components.requireVHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.PhotoView2 + +class PhotoViewHolder( + parent: ViewGroup, + callback: ImageViewerAdapterListener, + val binding: ItemImageviewerPhotoBinding = + ItemImageviewerPhotoBinding.inflate(LayoutInflater.from(parent.context), parent, false) +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.photoView.setListener(object : PhotoView2.Listener { + override fun onDrag(view: PhotoView2, fraction: Float) = callback.onDrag(this@PhotoViewHolder, view, fraction) + override fun onRestore(view: PhotoView2, fraction: Float) = callback.onRestore(this@PhotoViewHolder, view, fraction) + override fun onRelease(view: PhotoView2) = callback.onRelease(this@PhotoViewHolder, view) + }) + requireVHCustomizer().initialize(ItemType.PHOTO, this) + } + + fun bind(item: Photo, position: Int) { + binding.photoView.setTag(R.id.viewer_adapter_item_key, item.id()) + binding.photoView.setTag(R.id.viewer_adapter_item_data, item) + binding.photoView.setTag(R.id.viewer_adapter_item_holder, this) + requireVHCustomizer().bind(ItemType.PHOTO, item, position,this) + requireImageLoader().load(binding.photoView, item, this) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt new file mode 100644 index 0000000..109c93f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ItemImageviewerSubsamplingBinding +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Components.requireVHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.SubsamplingScaleImageView2 + +class SubsamplingViewHolder( + parent: ViewGroup, + callback: ImageViewerAdapterListener, + val binding: ItemImageviewerSubsamplingBinding = + ItemImageviewerSubsamplingBinding.inflate(LayoutInflater.from(parent.context), parent, false) +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.subsamplingView.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_START) + binding.subsamplingView.setListener(object : SubsamplingScaleImageView2.Listener { + override fun onDrag(view: SubsamplingScaleImageView2, fraction: Float) = callback.onDrag(this@SubsamplingViewHolder, view, fraction) + override fun onRestore(view: SubsamplingScaleImageView2, fraction: Float) = callback.onRestore(this@SubsamplingViewHolder, view, fraction) + override fun onRelease(view: SubsamplingScaleImageView2) = callback.onRelease(this@SubsamplingViewHolder, view) + }) + requireVHCustomizer().initialize(ItemType.SUBSAMPLING, this) + } + + fun bind(item: Photo, position: Int) { + binding.subsamplingView.setTag(R.id.viewer_adapter_item_key, item.id()) + binding.subsamplingView.setTag(R.id.viewer_adapter_item_data, item) + binding.subsamplingView.setTag(R.id.viewer_adapter_item_holder, this) + requireVHCustomizer().bind(ItemType.SUBSAMPLING, item, position,this) + requireImageLoader().load(binding.subsamplingView, item, this) + } +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt new file mode 100644 index 0000000..1168d51 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt @@ -0,0 +1,6 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class UnknownViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt new file mode 100644 index 0000000..af1cb7f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ItemImageviewerVideoBinding +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Components.requireVHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView2 + +class VideoViewHolder( + parent: ViewGroup, + callback: ImageViewerAdapterListener, + val binding: ItemImageviewerVideoBinding = + ItemImageviewerVideoBinding.inflate(LayoutInflater.from(parent.context), parent, false) +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.videoView.addListener(object : ExoVideoView2.Listener { + override fun onDrag(view: ExoVideoView2, fraction: Float) = callback.onDrag(this@VideoViewHolder, view, fraction) + override fun onRestore(view: ExoVideoView2, fraction: Float) = callback.onRestore(this@VideoViewHolder, view, fraction) + override fun onRelease(view: ExoVideoView2) = callback.onRelease(this@VideoViewHolder, view) + }) + requireVHCustomizer().initialize(ItemType.VIDEO, this) + } + + fun bind(item: Photo, position: Int) { + binding.videoView.setTag(R.id.viewer_adapter_item_key, item.id()) + binding.videoView.setTag(R.id.viewer_adapter_item_data, item) + binding.videoView.setTag(R.id.viewer_adapter_item_holder, this) + requireVHCustomizer().bind(ItemType.VIDEO, item,position, this) + requireImageLoader().load(binding.videoView, item, this) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt new file mode 100644 index 0000000..fc43bca --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt @@ -0,0 +1,46 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.animation.DecelerateInterpolator +import androidx.constraintlayout.widget.ConstraintLayout +import com.remax.visualnovel.widget.imageviewer.utils.Config.DURATION_BG + +class BackgroundView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { + private val argbEvaluator by lazy { ArgbEvaluator() } + private var bgColor = Color.TRANSPARENT + private var animator: ValueAnimator? = null + + fun changeToBackgroundColor(targetColor: Int) { + animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = DURATION_BG + interpolator = DecelerateInterpolator() + val start = bgColor + addUpdateListener { + val fraction = it.animatedValue as Float + updateBackgroundColor(fraction, start, targetColor) + } + } + animator?.start() + } + + fun updateBackgroundColor(fraction: Float, startValue: Int, endValue: Int) { + setBackgroundColor(argbEvaluator.evaluate(fraction, startValue, endValue) as Int) + } + + override fun setBackgroundColor(color: Int) { + super.setBackgroundColor(color) + bgColor = color + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animator?.cancel() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt new file mode 100644 index 0000000..f3b16f3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.widget.FrameLayout +import com.remax.visualnovel.widget.imageviewer.utils.TransitionEndHelper +import com.remax.visualnovel.widget.imageviewer.utils.TransitionStartHelper + +class InterceptLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) + : FrameLayout(context, attrs, defStyleAttr) { + override fun onInterceptTouchEvent(ev: MotionEvent?) = TransitionStartHelper.transitionAnimating || TransitionEndHelper.transitionAnimating +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt new file mode 100644 index 0000000..de46ccd --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt @@ -0,0 +1,123 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.widget.imageviewer.ImageViewerViewModel +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.utils.ViewModelUtils.provideViewModel +import com.github.chrisbanes.photoview.PhotoView +import timber.log.Timber +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class PhotoView2 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : PhotoView(context, attrs, defStyleAttr) { + interface Listener { + fun onDrag(view: PhotoView2, fraction: Float) + fun onRestore(view: PhotoView2, fraction: Float) + fun onRelease(view: PhotoView2) + } + + private val viewModel by lazy { provideViewModel(this) } + + private val scaledTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop * Config.SWIPE_TOUCH_SLOP } + private val dismissEdge by lazy { height * Config.DISMISS_FRACTION } + private var singleTouch = true + private var fakeDragOffset = 0f + private var lastX = 0f + private var lastY = 0f + private var listener: Listener? = null + + fun setListener(listener: Listener?) { + this.listener = listener + } + + override fun onDraw(canvas: Canvas) { + try { + super.onDraw(canvas) + } catch (e: Exception) { + Timber.e("PhotoView2 onDraw Exception" + e.localizedMessage) + } + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (Config.SWIPE_DISMISS && Config.VIEWER_ORIENTATION == ViewPager2.ORIENTATION_HORIZONTAL) { + handleDispatchTouchEvent(event) + } + return super.dispatchTouchEvent(event) + } + + private fun handleDispatchTouchEvent(event: MotionEvent?) { + when (event?.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + setSingleTouch(false) + animate().translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> up() + MotionEvent.ACTION_MOVE -> { + Timber.d("handleDispatchTouchEvent singleTouch:$singleTouch") + if (singleTouch) { +// if (singleTouch && scale == 1f) { + if (lastX == 0f) lastX = event.rawX + if (lastY == 0f) lastY = event.rawY + val offsetX = event.rawX - lastX + val offsetY = event.rawY - lastY + fakeDrag(offsetX, offsetY) + } + } + } + } + + private fun fakeDrag(offsetX: Float, offsetY: Float) { + if (fakeDragOffset == 0f) { + if (offsetY > scaledTouchSlop) fakeDragOffset = scaledTouchSlop + else if (offsetY < -scaledTouchSlop) fakeDragOffset = -scaledTouchSlop + } + if (fakeDragOffset != 0f) { + val fixedOffsetY = offsetY - fakeDragOffset + setAllowParentInterceptOnEdge(false) + val fraction = abs(max(-1f, min(1f, fixedOffsetY / height))) + val fakeScale = 1 - min(0.4f, fraction) + scaleX = fakeScale + scaleY = fakeScale + translationY = fixedOffsetY + translationX = offsetX / 2 + listener?.onDrag(this, fraction) + } + } + + private fun up() { + setAllowParentInterceptOnEdge(true) + setSingleTouch(true) + fakeDragOffset = 0f + lastX = 0f + lastY = 0f + + if (abs(translationY) > dismissEdge) { + listener?.onRelease(this) + } else { + val offsetY = translationY + val fraction = min(1f, offsetY / height) + listener?.onRestore(this, fraction) + + animate().translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + } + + private fun setSingleTouch(value: Boolean) { + singleTouch = value + viewModel?.setViewerUserInputEnabled(value) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animate().cancel() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt new file mode 100644 index 0000000..9d25ec0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt @@ -0,0 +1,135 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.content.Context +import android.graphics.PointF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.viewpager2.widget.ViewPager2 +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.remax.visualnovel.widget.imageviewer.ImageViewerViewModel +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.utils.ViewModelUtils.provideViewModel +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class SubsamplingScaleImageView2 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) + : SubsamplingScaleImageView(context, attrs) { + interface Listener { + fun onDrag(view: SubsamplingScaleImageView2, fraction: Float) + fun onRestore(view: SubsamplingScaleImageView2, fraction: Float) + fun onRelease(view: SubsamplingScaleImageView2) + } + + private val viewModel by lazy { provideViewModel(this) } + private var initCenter: PointF? = null + private var changedCenter: PointF? = null + private var initScale: Float? = null + private val scaledTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop * Config.SWIPE_TOUCH_SLOP } + private val dismissEdge by lazy { height * Config.DISMISS_FRACTION } + private var imageLoaded = false + private var singleTouch = true + private var fakeDragOffset = 0f + private var lastX = 0f + private var lastY = 0f + private var listener: Listener? = null + + init { + setOnStateChangedListener(object : OnStateChangedListener { + override fun onScaleChanged(newScale: Float, origin: Int) = Unit + override fun onCenterChanged(newCenter: PointF?, origin: Int) { + changedCenter = newCenter + } + }) + setOnImageEventListener(object : DefaultOnImageEventListener() { + override fun onImageLoaded() { imageLoaded = true } + }) + } + + fun setListener(listener: Listener?) { + this.listener = listener + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (Config.SWIPE_DISMISS && Config.VIEWER_ORIENTATION == ViewPager2.ORIENTATION_HORIZONTAL) { + handleDispatchTouchEvent(event) + } + return super.dispatchTouchEvent(event) + } + + private fun handleDispatchTouchEvent(event: MotionEvent?) { + if (!imageLoaded) return + if (initScale == null) { + initScale = scale + initCenter = center + changedCenter = center + } + when (event?.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + setSingleTouch(false) + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> up() + MotionEvent.ACTION_MOVE -> { + if (singleTouch && scale == initScale && (changedCenter?.y ?: initCenter?.y) == (initCenter?.y ?: 0f)) { + if (lastX == 0f) lastX = event.rawX + if (lastY == 0f) lastY = event.rawY + val offsetX = event.rawX - lastX + val offsetY = event.rawY - lastY + fakeDrag(offsetX, offsetY) + } + } + } + } + + private fun fakeDrag(offsetX: Float, offsetY: Float) { + if (fakeDragOffset == 0f) { + if (offsetY > scaledTouchSlop) fakeDragOffset = scaledTouchSlop + else if (offsetY < -scaledTouchSlop) fakeDragOffset = -scaledTouchSlop + } + if (fakeDragOffset != 0f) { + val fixedOffsetY = offsetY - fakeDragOffset + parent?.requestDisallowInterceptTouchEvent(true) + val fraction = abs(max(-1f, min(1f, fixedOffsetY / height))) + val fakeScale = 1 - min(0.4f, fraction) + scaleX = fakeScale + scaleY = fakeScale + translationY = fixedOffsetY + translationX = offsetX / 2 + listener?.onDrag(this, fraction) + } + } + + private fun up() { + parent?.requestDisallowInterceptTouchEvent(false) + setSingleTouch(true) + fakeDragOffset = 0f + lastX = 0f + lastY = 0f + + if (abs(translationY) > dismissEdge) { + listener?.onRelease(this) + } else { + val offsetY = translationY + val fraction = min(1f, offsetY / height) + listener?.onRestore(this, fraction) + + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + } + + private fun setSingleTouch(value: Boolean) { + singleTouch = value + viewModel?.setViewerUserInputEnabled(value) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animate().cancel() + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt new file mode 100644 index 0000000..8fe18e4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt @@ -0,0 +1,182 @@ +package com.remax.visualnovel.widget.imageviewer.widgets.video + +import android.content.Context +import android.util.AttributeSet +import android.view.TextureView +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.analytics.AnalyticsListener +import com.google.android.exoplayer2.util.EventLogger +import com.google.android.exoplayer2.video.VideoSize +import kotlin.math.max +import kotlin.math.min + +open class ExoVideoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr) { + interface VideoRenderedListener { + fun onRendered(view: ExoVideoView) + } + + interface MediaItemProvider { + fun provide(playUrl: String): List? + } + + companion object { + const val SCALE_TYPE_FIT_XY = 0 + const val SCALE_TYPE_FIT_CENTER = 1 + const val SCALE_TYPE_CENTER_CROP = 2 + } + + private val logger by lazy { EventLogger(null) } + private var exoPlayer: SimpleExoPlayer? = null + private var videoRenderedCallback: VideoRenderedListener? = null + private val listeners = mutableListOf() + private var playUrl: String? = null + protected var prepared = false + private var st = Config.VIDEO_SCALE_TYPE + val scaleType get() = st + private var ar = true + val autoRelease get() = ar + + fun prepare(url: String) { + playUrl = url + } + + fun resume( + provider: MediaItemProvider? = null + ) { + val url = playUrl ?: return + if (exoPlayer == null) { + prepared = false + alpha = 0f + newExoPlayer() + exoPlayer?.setMediaItems(provider?.provide(url) ?: listOf(MediaItem.fromUri(url))) + exoPlayer?.prepare() + } + exoPlayer?.playWhenReady = true + } + + fun pause() { + exoPlayer?.playWhenReady = false + } + + fun reset() { + exoPlayer?.seekTo(0) + exoPlayer?.playWhenReady = false + } + + fun release() { + val player = exoPlayer ?: return + player.playWhenReady = false + player.setVideoTextureView(null) + player.removeListener(videoListener) + player.removeAnalyticsListener(logger) + listeners.toList().forEach { player.removeAnalyticsListener(it) } + player.release() + exoPlayer = null + } + + fun setScaleType(scaleType: Int) { + st = scaleType + } + + fun setAutoRelease(autoRelease: Boolean) { + ar = autoRelease + } + + fun setVideoRenderedCallback(listener: VideoRenderedListener?) { + videoRenderedCallback = listener + } + + fun addAnalyticsListener(analyticsListener: AnalyticsListener) { + if (!listeners.contains(analyticsListener)) { + listeners.add(analyticsListener) + } + } + + fun player( + provider: MediaItemProvider? = null + ): ExoPlayer? { + val url = playUrl ?: return null + if (exoPlayer == null) { + prepared = false + alpha = 0f + newExoPlayer() + exoPlayer?.setMediaItems(provider?.provide(url) ?: listOf(MediaItem.fromUri(url))) + exoPlayer?.prepare() + } + return exoPlayer + } + + private fun newExoPlayer(): ExoPlayer { + release() + return SimpleExoPlayer.Builder(context).build().also { + it.setVideoTextureView(this) + it.addListener(videoListener) + if (Config.DEBUG) it.addAnalyticsListener(logger) + listeners.toList().forEach { userListener -> it.addAnalyticsListener(userListener) } + exoPlayer = it + } + } + + private val videoListener = object : Player.Listener { + override fun onVideoSizeChanged( + videoSize: VideoSize + ) { + updateTextureViewSize(videoSize.width, videoSize.height) + } + } + + private fun updateTextureViewSize(videoWidth: Int, videoHeight: Int) { + when (st) { + SCALE_TYPE_FIT_CENTER -> fitCenter(videoWidth, videoHeight) + SCALE_TYPE_CENTER_CROP -> centerCrop(videoWidth, videoHeight) + SCALE_TYPE_FIT_XY -> fitXY(videoWidth, videoHeight) + } + invalidate() + alpha = 1f + videoRenderedCallback?.onRendered(this) + prepared = true + } + + private fun fitCenter(videoWidth: Int, videoHeight: Int) { + val sx = width * 1f / videoWidth + val sy = height * 1f / videoHeight + val matrix = android.graphics.Matrix() + matrix.postScale(videoWidth * 1f / width, videoHeight * 1f / height) + matrix.postScale(min(sx, sy), min(sx, sy)) + matrix.postTranslate( + if (sx > sy) (width - videoWidth * sy) / 2 else 0f, + if (sx > sy) 0f else (height - videoHeight * sx) / 2 + ) + setTransform(matrix) + } + + private fun centerCrop(videoWidth: Int, videoHeight: Int) { + val sx = width * 1f / videoWidth + val sy = height * 1f / videoHeight + val matrix = android.graphics.Matrix() + matrix.postScale(videoWidth * 1f / width, videoHeight * 1f / height) + matrix.postScale(max(sx, sy), max(sx, sy)) + matrix.postTranslate( + if (sx < sy) (width - videoWidth * sy) / 2 else 0f, + if (sx < sy) 0f else (height - videoHeight * sx) / 2 + ) + setTransform(matrix) + } + + private fun fitXY(videoWidth: Int, videoHeight: Int) { + // default + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + if (autoRelease) release() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt new file mode 100644 index 0000000..50b32de --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt @@ -0,0 +1,147 @@ +package com.remax.visualnovel.widget.imageviewer.widgets.video + +import android.content.Context +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.widget.imageviewer.utils.Config +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class ExoVideoView2 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ExoVideoView(context, attrs, defStyleAttr), + View.OnTouchListener { + + interface Listener { + fun onDrag(view: ExoVideoView2, fraction: Float) + fun onRestore(view: ExoVideoView2, fraction: Float) + fun onRelease(view: ExoVideoView2) + } + + private val scaledTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop * Config.SWIPE_TOUCH_SLOP } + private val dismissEdge by lazy { height * Config.DISMISS_FRACTION } + private var singleTouch = true + private var fakeDragOffset = 0f + private var lastX = 0f + private var lastY = 0f + private val listeners = mutableListOf() + private var clickListener: OnClickListener? = null + private var longClickListener: OnLongClickListener? = null + + init { + setOnTouchListener(this) + } + + fun addListener(listener: Listener) { + if (!listeners.contains(listener)) { + listeners.add(listener) + } + } + + override fun setOnClickListener(listener: OnClickListener?) { + clickListener = listener + } + + override fun setOnLongClickListener(listener: OnLongClickListener?) { + longClickListener = listener + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (Config.SWIPE_DISMISS && Config.VIEWER_ORIENTATION == ViewPager2.ORIENTATION_HORIZONTAL) { + handleDispatchTouchEvent(event) + } + return super.dispatchTouchEvent(event) + } + + override fun onTouch(v: View?, event: MotionEvent): Boolean { + gestureDetector.onTouchEvent(event) + return true + } + + private fun handleDispatchTouchEvent(event: MotionEvent?) { + if (!prepared) return + + when (event?.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + singleTouch = false + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> up() + MotionEvent.ACTION_MOVE -> { + if (singleTouch) { + if (lastX == 0f) lastX = event.rawX + if (lastY == 0f) lastY = event.rawY + val offsetX = event.rawX - lastX + val offsetY = event.rawY - lastY + fakeDrag(offsetX, offsetY) + } + } + } + } + + private fun fakeDrag(offsetX: Float, offsetY: Float) { + if (fakeDragOffset == 0f) { + if (offsetY > scaledTouchSlop) fakeDragOffset = scaledTouchSlop + else if (offsetY < -scaledTouchSlop) fakeDragOffset = -scaledTouchSlop + } + if (fakeDragOffset != 0f) { + val fixedOffsetY = offsetY - fakeDragOffset + parent?.requestDisallowInterceptTouchEvent(true) + val fraction = abs(max(-1f, min(1f, fixedOffsetY / height))) + val fakeScale = 1 - min(0.4f, fraction) + scaleX = fakeScale + scaleY = fakeScale + translationY = fixedOffsetY + translationX = offsetX / 2 + listeners.toList().forEach { it.onDrag(this, fraction) } + } + } + + private fun up() { + parent?.requestDisallowInterceptTouchEvent(false) + singleTouch = true + fakeDragOffset = 0f + lastX = 0f + lastY = 0f + + if (abs(translationY) > dismissEdge) { + listeners.toList().forEach { it.onRelease(this) } + } else { + val offsetY = translationY + val fraction = min(1f, offsetY / height) + listeners.toList().forEach { it.onRestore(this, fraction) } + + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animate().cancel() + } + + private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + // forward long click listener + override fun onLongPress(e: MotionEvent) { + longClickListener?.onLongClick(this@ExoVideoView2) + } + }).apply { + setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + clickListener?.onClick(this@ExoVideoView2) + return true + } + + override fun onDoubleTapEvent(e: MotionEvent) = false + override fun onDoubleTap(e: MotionEvent): Boolean = true + }) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt new file mode 100644 index 0000000..ee17305 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.widget.indicator + +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import net.lucode.hackware.magicindicator.MagicIndicator + +object ViewPager2Helper { + fun bind(magicIndicator: MagicIndicator, viewPager: ViewPager2, onPageSelected: ((Int) -> Unit)? = null) { + viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + super.onPageScrolled(position, positionOffset, positionOffsetPixels) + magicIndicator.onPageScrolled(position, positionOffset, positionOffsetPixels) + } + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + magicIndicator.onPageSelected(position) + onPageSelected?.invoke(position) + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + magicIndicator.onPageScrollStateChanged(state) + } + }) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt new file mode 100644 index 0000000..9fba54f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +/** + * Created by HJW on 2020/11/19 + * @params 横条目数量,行间距,列间距 + */ +class GridSpaceItemDecoration( + private val spanCount: Int, + private val rowSpacing: Int = 0,//行间距 + private val columnSpacing: Int = 0, //列间距 + private val hasHead: Boolean = false +) : + RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) // 获取view 在adapter中的位置。 + + if (hasHead) { + if (position != 0) { + var column = position % spanCount - 1 // view 所在的列 + if (column == -1) column = spanCount - 1 + outRect.left = column * columnSpacing / spanCount // column * (列间距 * (1f / 列数)) + + outRect.right = columnSpacing - (column + 1) * columnSpacing / spanCount // 列间距 - (column + 1) * (列间距 * (1f /列数)) + + } + outRect.bottom = rowSpacing // item top + } else { + val column = position % spanCount // view 所在的列 + + outRect.left = column * columnSpacing / spanCount // column * (列间距 * (1f / 列数)) + outRect.right = columnSpacing - (column + 1) * columnSpacing / spanCount // 列间距 - (column + 1) * (列间距 * (1f /列数)) + + outRect.bottom = rowSpacing // item top + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt new file mode 100644 index 0000000..5672a85 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +/** + * Created by HJW on 2020/11/19 + * @params 横条目数量,行间距,列间距 + */ +class HorizontalGridSpaceItemDecoration( + private val spanCount: Int, + private val space: Int, //左右间距 + private val startSpace: Int = 0, + private val endSpace: Int = 0, + private val headerCount: Int = 0 +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) // 获取view 在adapter中的位置。 + + if (position < headerCount) { + return + } + + val totalCount = parent.adapter!!.itemCount + var totalRow = totalCount / spanCount // 总行数 + val d = totalCount % spanCount + if (d > 0) { + totalRow++ + } + var row = (position + 1) / spanCount // view 所在的行 + val p = (position + 1) % spanCount + if (p > 0) { + row++ + } + + outRect.left = if (position < spanCount) startSpace else space / 2 + outRect.right = if (row == totalRow) endSpace else space / 2 + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt new file mode 100644 index 0000000..bdb1d17 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt @@ -0,0 +1,56 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +//定义2个Item之间的距离 +class HorizontalItemDecoration(private val space: Int, private val startSpace: Int = 0, private val endSpace: Int = 0) : ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + val totalCount = parent.adapter!!.itemCount + if ((parent.layoutManager as LinearLayoutManager).reverseLayout) { + when (position) { + 0 -> { //第一个 + outRect.right = startSpace + outRect.left = space / 2 + } + + totalCount - 1 -> { //最后一个 + outRect.right = space / 2 + outRect.left = endSpace + } + + else -> { //中间其它的 + outRect.left = space / 2 + outRect.right = space / 2 + } + } + if (totalCount == 1) { + outRect.left = endSpace + } + } else { + when (position) { + 0 -> { //第一个 + outRect.left = startSpace + outRect.right = space / 2 + } + + totalCount - 1 -> { //最后一个 + outRect.left = space / 2 + outRect.right = endSpace + } + + else -> { //中间其它的 + outRect.left = space / 2 + outRect.right = space / 2 + } + } + if (totalCount == 1) { + outRect.right = endSpace + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt new file mode 100644 index 0000000..1196b9c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt @@ -0,0 +1,36 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class SpaceItemDecoration constructor( + private var top: Int = 0, + private var left: Int = 0, + private var bottom: Int = 0, + private var right: Int = 0, + private var headerCount: Int = 0 +) : RecyclerView.ItemDecoration() { + + constructor(horizontal: Int, vertical: Int, headerCount: Int = 0) : this() { + top = vertical + bottom = vertical + + left = horizontal + right = horizontal + + this.headerCount = headerCount + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + super.getItemOffsets(outRect, view, parent, state) + val position = parent.getChildAdapterPosition(view) + if (position < headerCount) { + return + } + if (top > 0) outRect.top = top + if (left > 0) outRect.left = left + if (bottom > 0) outRect.bottom = bottom + if (right > 0) outRect.right = right + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt new file mode 100644 index 0000000..8cfce0f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt @@ -0,0 +1,38 @@ +package com.crushlevel.android.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +//定义2个Item之间的距离 +class VerticalItemDecoration( + private val space: Int, + private val topSpace: Int = 0, + private val bottomSpace: Int = 0, + private val headerCount: Int = 0 +) : ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + if (position < headerCount) { + return + } + val totalCount = parent.adapter!!.itemCount + when (position) { + 0 -> { //第一个 + outRect.top = topSpace + outRect.bottom = space / 2 + } + + totalCount - 1 -> { //最后一个 + outRect.top = space / 2 + outRect.bottom = bottomSpace + } + + else -> { //中间其它的 + outRect.top = space / 2 + outRect.bottom = space / 2 + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java new file mode 100644 index 0000000..22577a1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java @@ -0,0 +1,18 @@ +package com.remax.visualnovel.widget.roundedimageview; + +import androidx.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.SOURCE) +@IntDef({ + Corner.TOP_LEFT, Corner.TOP_RIGHT, + Corner.BOTTOM_LEFT, Corner.BOTTOM_RIGHT +}) +public @interface Corner { + int TOP_LEFT = 0; + int TOP_RIGHT = 1; + int BOTTOM_RIGHT = 2; + int BOTTOM_LEFT = 3; +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java new file mode 100644 index 0000000..1e515bf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java @@ -0,0 +1,618 @@ +package com.remax.visualnovel.widget.roundedimageview; + +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.widget.ImageView.ScaleType; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import java.util.HashSet; +import java.util.Set; + +@SuppressWarnings("UnusedDeclaration") +public class RoundedDrawable extends Drawable { + + public static final String TAG = "RoundedDrawable"; + public static final int DEFAULT_BORDER_COLOR = Color.BLACK; + + private final RectF mBounds = new RectF(); + private final RectF mDrawableRect = new RectF(); + private final RectF mBitmapRect = new RectF(); + private final Bitmap mBitmap; + private final Paint mBitmapPaint; + private final int mBitmapWidth; + private final int mBitmapHeight; + private final RectF mBorderRect = new RectF(); + private final Paint mBorderPaint; + private final Matrix mShaderMatrix = new Matrix(); + private final RectF mSquareCornersRect = new RectF(); + + private Shader.TileMode mTileModeX = Shader.TileMode.CLAMP; + private Shader.TileMode mTileModeY = Shader.TileMode.CLAMP; + private boolean mRebuildShader = true; + + private float mCornerRadius = 0f; + // [ topLeft, topRight, bottomLeft, bottomRight ] + private final boolean[] mCornersRounded = new boolean[] { true, true, true, true }; + + private boolean mOval = false; + private float mBorderWidth = 0; + private ColorStateList mBorderColor = ColorStateList.valueOf(DEFAULT_BORDER_COLOR); + private ScaleType mScaleType = ScaleType.FIT_CENTER; + + public RoundedDrawable(Bitmap bitmap) { + mBitmap = bitmap; + + mBitmapWidth = bitmap.getWidth(); + mBitmapHeight = bitmap.getHeight(); + mBitmapRect.set(0, 0, mBitmapWidth, mBitmapHeight); + + mBitmapPaint = new Paint(); + mBitmapPaint.setStyle(Paint.Style.FILL); + mBitmapPaint.setAntiAlias(true); + + mBorderPaint = new Paint(); + mBorderPaint.setStyle(Paint.Style.STROKE); + mBorderPaint.setAntiAlias(true); + mBorderPaint.setColor(mBorderColor.getColorForState(getState(), DEFAULT_BORDER_COLOR)); + mBorderPaint.setStrokeWidth(mBorderWidth); + } + + public static RoundedDrawable fromBitmap(Bitmap bitmap) { + if (bitmap != null) { + return new RoundedDrawable(bitmap); + } else { + return null; + } + } + + public static Drawable fromDrawable(Drawable drawable) { + if (drawable != null) { + if (drawable instanceof RoundedDrawable) { + // just return if it's already a RoundedDrawable + return drawable; + } else if (drawable instanceof LayerDrawable) { + ConstantState cs = drawable.mutate().getConstantState(); + LayerDrawable ld = (LayerDrawable) (cs != null ? cs.newDrawable() : drawable); + + int num = ld.getNumberOfLayers(); + + // loop through layers to and change to RoundedDrawables if possible + for (int i = 0; i < num; i++) { + Drawable d = ld.getDrawable(i); + ld.setDrawableByLayerId(ld.getId(i), fromDrawable(d)); + } + return ld; + } + + // try to get a bitmap from the drawable and + Bitmap bm = drawableToBitmap(drawable); + if (bm != null) { + return new RoundedDrawable(bm); + } + } + return drawable; + } + + public static Bitmap drawableToBitmap(Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable) drawable).getBitmap(); + } + + Bitmap bitmap; + int width = Math.max(drawable.getIntrinsicWidth(), 2); + int height = Math.max(drawable.getIntrinsicHeight(), 2); + try { + bitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + } catch (Throwable e) { + e.printStackTrace(); + bitmap = null; + } + + return bitmap; + } + + public Bitmap getSourceBitmap() { + return mBitmap; + } + + @Override + public boolean isStateful() { + return mBorderColor.isStateful(); + } + + @Override + protected boolean onStateChange(int[] state) { + int newColor = mBorderColor.getColorForState(state, 0); + if (mBorderPaint.getColor() != newColor) { + mBorderPaint.setColor(newColor); + return true; + } else { + return super.onStateChange(state); + } + } + + private void updateShaderMatrix() { + float scale; + float dx; + float dy; + + switch (mScaleType) { + case CENTER: + mBorderRect.set(mBounds); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + + mShaderMatrix.reset(); + mShaderMatrix.setTranslate((int) ((mBorderRect.width() - mBitmapWidth) * 0.5f + 0.5f), + (int) ((mBorderRect.height() - mBitmapHeight) * 0.5f + 0.5f)); + break; + + case CENTER_CROP: + mBorderRect.set(mBounds); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + + mShaderMatrix.reset(); + + dx = 0; + dy = 0; + + if (mBitmapWidth * mBorderRect.height() > mBorderRect.width() * mBitmapHeight) { + scale = mBorderRect.height() / (float) mBitmapHeight; + dx = (mBorderRect.width() - mBitmapWidth * scale) * 0.5f; + } else { + scale = mBorderRect.width() / (float) mBitmapWidth; + dy = (mBorderRect.height() - mBitmapHeight * scale) * 0.5f; + } + + mShaderMatrix.setScale(scale, scale); + mShaderMatrix.postTranslate((int) (dx + 0.5f) + mBorderWidth / 2, + (int) (dy + 0.5f) + mBorderWidth / 2); + break; + + case CENTER_INSIDE: + mShaderMatrix.reset(); + + if (mBitmapWidth <= mBounds.width() && mBitmapHeight <= mBounds.height()) { + scale = 1.0f; + } else { + scale = Math.min(mBounds.width() / (float) mBitmapWidth, + mBounds.height() / (float) mBitmapHeight); + } + + dx = (int) ((mBounds.width() - mBitmapWidth * scale) * 0.5f + 0.5f); + dy = (int) ((mBounds.height() - mBitmapHeight * scale) * 0.5f + 0.5f); + + mShaderMatrix.setScale(scale, scale); + mShaderMatrix.postTranslate(dx, dy); + + mBorderRect.set(mBitmapRect); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + default: + case FIT_CENTER: + mBorderRect.set(mBitmapRect); + mShaderMatrix.setRectToRect(mBitmapRect, mBounds, Matrix.ScaleToFit.CENTER); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + case FIT_END: + mBorderRect.set(mBitmapRect); + mShaderMatrix.setRectToRect(mBitmapRect, mBounds, Matrix.ScaleToFit.END); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + case FIT_START: + mBorderRect.set(mBitmapRect); + mShaderMatrix.setRectToRect(mBitmapRect, mBounds, Matrix.ScaleToFit.START); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + case FIT_XY: + mBorderRect.set(mBounds); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.reset(); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + } + + mDrawableRect.set(mBorderRect); + mRebuildShader = true; + } + + @Override + protected void onBoundsChange(@NonNull Rect bounds) { + super.onBoundsChange(bounds); + + mBounds.set(bounds); + + updateShaderMatrix(); + } + + @Override + public void draw(@NonNull Canvas canvas) { + if (mRebuildShader) { + BitmapShader bitmapShader = new BitmapShader(mBitmap, mTileModeX, mTileModeY); + if (mTileModeX == Shader.TileMode.CLAMP && mTileModeY == Shader.TileMode.CLAMP) { + bitmapShader.setLocalMatrix(mShaderMatrix); + } + mBitmapPaint.setShader(bitmapShader); + mRebuildShader = false; + } + + if (mOval) { + if (mBorderWidth > 0) { + canvas.drawOval(mDrawableRect, mBitmapPaint); + canvas.drawOval(mBorderRect, mBorderPaint); + } else { + canvas.drawOval(mDrawableRect, mBitmapPaint); + } + } else { + if (any(mCornersRounded)) { + float radius = mCornerRadius; + if (mBorderWidth > 0) { + canvas.drawRoundRect(mDrawableRect, radius, radius, mBitmapPaint); + canvas.drawRoundRect(mBorderRect, radius, radius, mBorderPaint); + redrawBitmapForSquareCorners(canvas); + redrawBorderForSquareCorners(canvas); + } else { + canvas.drawRoundRect(mDrawableRect, radius, radius, mBitmapPaint); + redrawBitmapForSquareCorners(canvas); + } + } else { + canvas.drawRect(mDrawableRect, mBitmapPaint); + if (mBorderWidth > 0) { + canvas.drawRect(mBorderRect, mBorderPaint); + } + } + } + } + + private void redrawBitmapForSquareCorners(Canvas canvas) { + if (all(mCornersRounded)) { + // no square corners + return; + } + + if (mCornerRadius == 0) { + return; // no round corners + } + + float left = mDrawableRect.left; + float top = mDrawableRect.top; + float right = left + mDrawableRect.width(); + float bottom = top + mDrawableRect.height(); + float radius = mCornerRadius; + + if (!mCornersRounded[Corner.TOP_LEFT]) { + mSquareCornersRect.set(left, top, left + radius, top + radius); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + + if (!mCornersRounded[Corner.TOP_RIGHT]) { + mSquareCornersRect.set(right - radius, top, right, radius); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_RIGHT]) { + mSquareCornersRect.set(right - radius, bottom - radius, right, bottom); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_LEFT]) { + mSquareCornersRect.set(left, bottom - radius, left + radius, bottom); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + } + + private void redrawBorderForSquareCorners(Canvas canvas) { + if (all(mCornersRounded)) { + // no square corners + return; + } + + if (mCornerRadius == 0) { + return; // no round corners + } + + float left = mDrawableRect.left; + float top = mDrawableRect.top; + float right = left + mDrawableRect.width(); + float bottom = top + mDrawableRect.height(); + float radius = mCornerRadius; + float offset = mBorderWidth / 2; + + if (!mCornersRounded[Corner.TOP_LEFT]) { + canvas.drawLine(left - offset, top, left + radius, top, mBorderPaint); + canvas.drawLine(left, top - offset, left, top + radius, mBorderPaint); + } + + if (!mCornersRounded[Corner.TOP_RIGHT]) { + canvas.drawLine(right - radius - offset, top, right, top, mBorderPaint); + canvas.drawLine(right, top - offset, right, top + radius, mBorderPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_RIGHT]) { + canvas.drawLine(right - radius - offset, bottom, right + offset, bottom, mBorderPaint); + canvas.drawLine(right, bottom - radius, right, bottom, mBorderPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_LEFT]) { + canvas.drawLine(left - offset, bottom, left + radius, bottom, mBorderPaint); + canvas.drawLine(left, bottom - radius, left, bottom, mBorderPaint); + } + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getAlpha() { + return mBitmapPaint.getAlpha(); + } + + @Override + public void setAlpha(int alpha) { + mBitmapPaint.setAlpha(alpha); + invalidateSelf(); + } + + @Override + public ColorFilter getColorFilter() { + return mBitmapPaint.getColorFilter(); + } + + @Override + public void setColorFilter(ColorFilter cf) { + mBitmapPaint.setColorFilter(cf); + invalidateSelf(); + } + + @Override + public void setDither(boolean dither) { + mBitmapPaint.setDither(dither); + invalidateSelf(); + } + + @Override + public void setFilterBitmap(boolean filter) { + mBitmapPaint.setFilterBitmap(filter); + invalidateSelf(); + } + + @Override + public int getIntrinsicWidth() { + return mBitmapWidth; + } + + @Override + public int getIntrinsicHeight() { + return mBitmapHeight; + } + + /** + * @return the corner radius. + */ + public float getCornerRadius() { + return mCornerRadius; + } + + /** + * @param corner the specific corner to get radius of. + * @return the corner radius of the specified corner. + */ + public float getCornerRadius(@Corner int corner) { + return mCornersRounded[corner] ? mCornerRadius : 0f; + } + + /** + * Sets all corners to the specified radius. + * + * @param radius the radius. + * @return the {@link RoundedDrawable} for chaining. + */ + public RoundedDrawable setCornerRadius(float radius) { + setCornerRadius(radius, radius, radius, radius); + return this; + } + + /** + * Sets the corner radius of one specific corner. + * + * @param corner the corner. + * @param radius the radius. + * @return the {@link RoundedDrawable} for chaining. + */ + public RoundedDrawable setCornerRadius(@Corner int corner, float radius) { + if (radius != 0 && mCornerRadius != 0 && mCornerRadius != radius) { + throw new IllegalArgumentException("Multiple nonzero corner radii not yet supported."); + } + + if (radius == 0) { + if (only(corner, mCornersRounded)) { + mCornerRadius = 0; + } + mCornersRounded[corner] = false; + } else { + if (mCornerRadius == 0) { + mCornerRadius = radius; + } + mCornersRounded[corner] = true; + } + + return this; + } + + /** + * Sets the corner radii of all the corners. + * + * @param topLeft top left corner radius. + * @param topRight top right corner radius + * @param bottomRight bototm right corner radius. + * @param bottomLeft bottom left corner radius. + * @return the {@link RoundedDrawable} for chaining. + */ + public RoundedDrawable setCornerRadius(float topLeft, float topRight, float bottomRight, + float bottomLeft) { + Set radiusSet = new HashSet<>(4); + radiusSet.add(topLeft); + radiusSet.add(topRight); + radiusSet.add(bottomRight); + radiusSet.add(bottomLeft); + + radiusSet.remove(0f); + + if (radiusSet.size() > 1) { + throw new IllegalArgumentException("Multiple nonzero corner radii not yet supported."); + } + + if (!radiusSet.isEmpty()) { + float radius = radiusSet.iterator().next(); + if (Float.isInfinite(radius) || Float.isNaN(radius) || radius < 0) { + throw new IllegalArgumentException("Invalid radius value: " + radius); + } + mCornerRadius = radius; + } else { + mCornerRadius = 0f; + } + + mCornersRounded[Corner.TOP_LEFT] = topLeft > 0; + mCornersRounded[Corner.TOP_RIGHT] = topRight > 0; + mCornersRounded[Corner.BOTTOM_RIGHT] = bottomRight > 0; + mCornersRounded[Corner.BOTTOM_LEFT] = bottomLeft > 0; + return this; + } + + public float getBorderWidth() { + return mBorderWidth; + } + + public RoundedDrawable setBorderWidth(float width) { + mBorderWidth = width; + mBorderPaint.setStrokeWidth(mBorderWidth); + return this; + } + + public int getBorderColor() { + return mBorderColor.getDefaultColor(); + } + + public RoundedDrawable setBorderColor(@ColorInt int color) { + return setBorderColor(ColorStateList.valueOf(color)); + } + + public ColorStateList getBorderColors() { + return mBorderColor; + } + + public RoundedDrawable setBorderColor(ColorStateList colors) { + mBorderColor = colors != null ? colors : ColorStateList.valueOf(0); + mBorderPaint.setColor(mBorderColor.getColorForState(getState(), DEFAULT_BORDER_COLOR)); + return this; + } + + public boolean isOval() { + return mOval; + } + + public RoundedDrawable setOval(boolean oval) { + mOval = oval; + return this; + } + + public ScaleType getScaleType() { + return mScaleType; + } + + public RoundedDrawable setScaleType(ScaleType scaleType) { + if (scaleType == null) { + scaleType = ScaleType.FIT_CENTER; + } + if (mScaleType != scaleType) { + mScaleType = scaleType; + updateShaderMatrix(); + } + return this; + } + + public Shader.TileMode getTileModeX() { + return mTileModeX; + } + + public RoundedDrawable setTileModeX(Shader.TileMode tileModeX) { + if (mTileModeX != tileModeX) { + mTileModeX = tileModeX; + mRebuildShader = true; + invalidateSelf(); + } + return this; + } + + public Shader.TileMode getTileModeY() { + return mTileModeY; + } + + public RoundedDrawable setTileModeY(Shader.TileMode tileModeY) { + if (mTileModeY != tileModeY) { + mTileModeY = tileModeY; + mRebuildShader = true; + invalidateSelf(); + } + return this; + } + + private static boolean only(int index, boolean[] booleans) { + for (int i = 0, len = booleans.length; i < len; i++) { + if (booleans[i] != (i == index)) { + return false; + } + } + return true; + } + + private static boolean any(boolean[] booleans) { + for (boolean b : booleans) { + if (b) { return true; } + } + return false; + } + + private static boolean all(boolean[] booleans) { + for (boolean b : booleans) { + if (b) { return false; } + } + return true; + } + + public Bitmap toBitmap() { + return drawableToBitmap(this); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java new file mode 100644 index 0000000..3f56a9a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java @@ -0,0 +1,587 @@ +package com.remax.visualnovel.widget.roundedimageview; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.ColorFilter; +import android.graphics.Shader; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.util.AttributeSet; + +import androidx.annotation.ColorInt; +import androidx.annotation.DimenRes; +import androidx.annotation.DrawableRes; +import androidx.appcompat.widget.AppCompatImageView; + +import com.remax.visualnovel.R; + +@SuppressWarnings("UnusedDeclaration") +public class RoundedImageView extends AppCompatImageView { + + // Constants for tile mode attributes + private static final int TILE_MODE_UNDEFINED = -2; + private static final int TILE_MODE_CLAMP = 0; + private static final int TILE_MODE_REPEAT = 1; + private static final int TILE_MODE_MIRROR = 2; + + public static final String TAG = "RoundedImageView"; + public static final float DEFAULT_RADIUS = 0f; + public static final float DEFAULT_BORDER_WIDTH = 0f; + public static final Shader.TileMode DEFAULT_TILE_MODE = Shader.TileMode.CLAMP; + private static final ScaleType[] SCALE_TYPES = { + ScaleType.MATRIX, + ScaleType.FIT_XY, + ScaleType.FIT_START, + ScaleType.FIT_CENTER, + ScaleType.FIT_END, + ScaleType.CENTER, + ScaleType.CENTER_CROP, + ScaleType.CENTER_INSIDE + }; + + private final float[] mCornerRadii = + new float[] { DEFAULT_RADIUS, DEFAULT_RADIUS, DEFAULT_RADIUS, DEFAULT_RADIUS }; + + private Drawable mBackgroundDrawable; + private ColorStateList mBorderColor = + ColorStateList.valueOf(RoundedDrawable.DEFAULT_BORDER_COLOR); + private float mBorderWidth = DEFAULT_BORDER_WIDTH; + private ColorFilter mColorFilter = null; + private boolean mColorMod = false; + private Drawable mDrawable; + private boolean mHasColorFilter = false; + private boolean mIsOval = false; + private boolean mMutateBackground = false; + private int mResource; + private int mBackgroundResource; + private ScaleType mScaleType; + private Shader.TileMode mTileModeX = DEFAULT_TILE_MODE; + private Shader.TileMode mTileModeY = DEFAULT_TILE_MODE; + + public RoundedImageView(Context context) { + super(context); + } + + public RoundedImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RoundedImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RoundedImageView, defStyle, 0); + + int index = a.getInt(R.styleable.RoundedImageView_android_scaleType, -1); + if (index >= 0) { + setScaleType(SCALE_TYPES[index]); + } else { + // default scaletype to FIT_CENTER + setScaleType(ScaleType.FIT_CENTER); + } + + float cornerRadiusOverride = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius, -1); + + mCornerRadii[Corner.TOP_LEFT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_top_left, -1); + mCornerRadii[Corner.TOP_RIGHT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_top_right, -1); + mCornerRadii[Corner.BOTTOM_RIGHT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_bottom_right, -1); + mCornerRadii[Corner.BOTTOM_LEFT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_bottom_left, -1); + + boolean any = false; + for (int i = 0, len = mCornerRadii.length; i < len; i++) { + if (mCornerRadii[i] < 0) { + mCornerRadii[i] = 0f; + } else { + any = true; + } + } + + if (!any) { + if (cornerRadiusOverride < 0) { + cornerRadiusOverride = DEFAULT_RADIUS; + } + for (int i = 0, len = mCornerRadii.length; i < len; i++) { + mCornerRadii[i] = cornerRadiusOverride; + } + } + + mBorderWidth = a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_border_width, -1); + if (mBorderWidth < 0) { + mBorderWidth = DEFAULT_BORDER_WIDTH; + } + + mBorderColor = a.getColorStateList(R.styleable.RoundedImageView_riv_border_color); + if (mBorderColor == null) { + mBorderColor = ColorStateList.valueOf(RoundedDrawable.DEFAULT_BORDER_COLOR); + } + + mMutateBackground = a.getBoolean(R.styleable.RoundedImageView_riv_mutate_background, false); + mIsOval = a.getBoolean(R.styleable.RoundedImageView_riv_oval, false); + + final int tileMode = a.getInt(R.styleable.RoundedImageView_riv_tile_mode, TILE_MODE_UNDEFINED); + if (tileMode != TILE_MODE_UNDEFINED) { + setTileModeX(parseTileMode(tileMode)); + setTileModeY(parseTileMode(tileMode)); + } + + final int tileModeX = + a.getInt(R.styleable.RoundedImageView_riv_tile_mode_x, TILE_MODE_UNDEFINED); + if (tileModeX != TILE_MODE_UNDEFINED) { + setTileModeX(parseTileMode(tileModeX)); + } + + final int tileModeY = + a.getInt(R.styleable.RoundedImageView_riv_tile_mode_y, TILE_MODE_UNDEFINED); + if (tileModeY != TILE_MODE_UNDEFINED) { + setTileModeY(parseTileMode(tileModeY)); + } + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(true); + + if (mMutateBackground) { + //noinspection deprecation + super.setBackgroundDrawable(mBackgroundDrawable); + } + + a.recycle(); + } + + private static Shader.TileMode parseTileMode(int tileMode) { + switch (tileMode) { + case TILE_MODE_CLAMP: + return Shader.TileMode.CLAMP; + case TILE_MODE_REPEAT: + return Shader.TileMode.REPEAT; + case TILE_MODE_MIRROR: + return Shader.TileMode.MIRROR; + default: + return null; + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + invalidate(); + } + + @Override + public ScaleType getScaleType() { + return mScaleType; + } + + @Override + public void setScaleType(ScaleType scaleType) { + assert scaleType != null; + + if (mScaleType != scaleType) { + mScaleType = scaleType; + + switch (scaleType) { + case CENTER: + case CENTER_CROP: + case CENTER_INSIDE: + case FIT_CENTER: + case FIT_START: + case FIT_END: + case FIT_XY: + super.setScaleType(ScaleType.FIT_XY); + break; + default: + super.setScaleType(scaleType); + break; + } + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + } + + @Override + public void setImageDrawable(Drawable drawable) { + mResource = 0; + mDrawable = RoundedDrawable.fromDrawable(drawable); + updateDrawableAttrs(); + super.setImageDrawable(mDrawable); + } + + @Override + public void setImageBitmap(Bitmap bm) { + mResource = 0; + mDrawable = RoundedDrawable.fromBitmap(bm); + updateDrawableAttrs(); + super.setImageDrawable(mDrawable); + } + + @Override + public void setImageResource(@DrawableRes int resId) { + if (mResource != resId) { + mResource = resId; + mDrawable = resolveResource(); + updateDrawableAttrs(); + super.setImageDrawable(mDrawable); + } + } + + @Override public void setImageURI(Uri uri) { + super.setImageURI(uri); + setImageDrawable(getDrawable()); + } + + private Drawable resolveResource() { + Resources rsrc = getResources(); + if (rsrc == null) { return null; } + + Drawable d = null; + + if (mResource != 0) { + try { + d = rsrc.getDrawable(mResource); + } catch (Exception e) { + // Don't try again. + mResource = 0; + } + } + return RoundedDrawable.fromDrawable(d); + } + + @Override + public void setBackground(Drawable background) { + setBackgroundDrawable(background); + } + + @Override + public void setBackgroundResource(@DrawableRes int resId) { + if (mBackgroundResource != resId) { + mBackgroundResource = resId; + mBackgroundDrawable = resolveBackgroundResource(); + setBackgroundDrawable(mBackgroundDrawable); + } + } + + @Override + public void setBackgroundColor(int color) { + mBackgroundDrawable = new ColorDrawable(color); + setBackgroundDrawable(mBackgroundDrawable); + } + + private Drawable resolveBackgroundResource() { + Resources rsrc = getResources(); + if (rsrc == null) { return null; } + + Drawable d = null; + + if (mBackgroundResource != 0) { + try { + d = rsrc.getDrawable(mBackgroundResource); + } catch (Exception e) { + // Don't try again. + mBackgroundResource = 0; + } + } + return RoundedDrawable.fromDrawable(d); + } + + private void updateDrawableAttrs() { + updateAttrs(mDrawable, mScaleType); + } + + private void updateBackgroundDrawableAttrs(boolean convert) { + if (mMutateBackground) { + if (convert) { + mBackgroundDrawable = RoundedDrawable.fromDrawable(mBackgroundDrawable); + } + updateAttrs(mBackgroundDrawable, ScaleType.FIT_XY); + } + } + + @Override public void setColorFilter(ColorFilter cf) { + if (mColorFilter != cf) { + mColorFilter = cf; + mHasColorFilter = true; + mColorMod = true; + applyColorMod(); + invalidate(); + } + } + + private void applyColorMod() { + // Only mutate and apply when modifications have occurred. This should + // not reset the mColorMod flag, since these filters need to be + // re-applied if the Drawable is changed. + if (mDrawable != null && mColorMod) { + mDrawable = mDrawable.mutate(); + if (mHasColorFilter) { + mDrawable.setColorFilter(mColorFilter); + } + //mDrawable.setXfermode(mXfermode); + //mDrawable.setAlpha(mAlpha * mViewAlphaScale >> 8); + } + } + + private void updateAttrs(Drawable drawable, ScaleType scaleType) { + if (drawable == null) { return; } + + if (drawable instanceof RoundedDrawable) { + ((RoundedDrawable) drawable) + .setScaleType(scaleType) + .setBorderWidth(mBorderWidth) + .setBorderColor(mBorderColor) + .setOval(mIsOval) + .setTileModeX(mTileModeX) + .setTileModeY(mTileModeY); + + if (mCornerRadii != null) { + ((RoundedDrawable) drawable).setCornerRadius( + mCornerRadii[Corner.TOP_LEFT], + mCornerRadii[Corner.TOP_RIGHT], + mCornerRadii[Corner.BOTTOM_RIGHT], + mCornerRadii[Corner.BOTTOM_LEFT]); + } + + applyColorMod(); + } else if (drawable instanceof LayerDrawable) { + // loop through layers to and set drawable attrs + LayerDrawable ld = ((LayerDrawable) drawable); + for (int i = 0, layers = ld.getNumberOfLayers(); i < layers; i++) { + updateAttrs(ld.getDrawable(i), scaleType); + } + } + } + + @Override + @Deprecated + public void setBackgroundDrawable(Drawable background) { + mBackgroundDrawable = background; + updateBackgroundDrawableAttrs(true); + //noinspection deprecation + super.setBackgroundDrawable(mBackgroundDrawable); + } + + /** + * @return the largest corner radius. + */ + public float getCornerRadius() { + return getMaxCornerRadius(); + } + + /** + * @return the largest corner radius. + */ + public float getMaxCornerRadius() { + float maxRadius = 0; + for (float r : mCornerRadii) { + maxRadius = Math.max(r, maxRadius); + } + return maxRadius; + } + + /** + * Get the corner radius of a specified corner. + * + * @param corner the corner. + * @return the radius. + */ + public float getCornerRadius(@Corner int corner) { + return mCornerRadii[corner]; + } + + /** + * Set all the corner radii from a dimension resource id. + * + * @param resId dimension resource id of radii. + */ + public void setCornerRadiusDimen(@DimenRes int resId) { + float radius = getResources().getDimension(resId); + setCornerRadius(radius, radius, radius, radius); + } + + /** + * Set the corner radius of a specific corner from a dimension resource id. + * + * @param corner the corner to set. + * @param resId the dimension resource id of the corner radius. + */ + public void setCornerRadiusDimen(@Corner int corner, @DimenRes int resId) { + setCornerRadius(corner, getResources().getDimensionPixelSize(resId)); + } + + /** + * Set the corner radii of all corners in px. + * + * @param radius the radius to set. + */ + public void setCornerRadius(float radius) { + setCornerRadius(radius, radius, radius, radius); + } + + /** + * Set the corner radius of a specific corner in px. + * + * @param corner the corner to set. + * @param radius the corner radius to set in px. + */ + public void setCornerRadius(@Corner int corner, float radius) { + if (mCornerRadii[corner] == radius) { + return; + } + mCornerRadii[corner] = radius; + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + /** + * Set the corner radii of each corner individually. Currently only one unique nonzero value is + * supported. + * + * @param topLeft radius of the top left corner in px. + * @param topRight radius of the top right corner in px. + * @param bottomRight radius of the bottom right corner in px. + * @param bottomLeft radius of the bottom left corner in px. + */ + public void setCornerRadius(float topLeft, float topRight, float bottomLeft, float bottomRight) { + if (mCornerRadii[Corner.TOP_LEFT] == topLeft + && mCornerRadii[Corner.TOP_RIGHT] == topRight + && mCornerRadii[Corner.BOTTOM_RIGHT] == bottomRight + && mCornerRadii[Corner.BOTTOM_LEFT] == bottomLeft) { + return; + } + + mCornerRadii[Corner.TOP_LEFT] = topLeft; + mCornerRadii[Corner.TOP_RIGHT] = topRight; + mCornerRadii[Corner.BOTTOM_LEFT] = bottomLeft; + mCornerRadii[Corner.BOTTOM_RIGHT] = bottomRight; + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + public float getBorderWidth() { + return mBorderWidth; + } + + public void setBorderWidth(@DimenRes int resId) { + setBorderWidth(getResources().getDimension(resId)); + } + + public void setBorderWidth(float width) { + if (mBorderWidth == width) { return; } + + mBorderWidth = width; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + @ColorInt + public int getBorderColor() { + return mBorderColor.getDefaultColor(); + } + + public void setBorderColor(@ColorInt int color) { + setBorderColor(ColorStateList.valueOf(color)); + } + + public ColorStateList getBorderColors() { + return mBorderColor; + } + + public void setBorderColor(ColorStateList colors) { + if (mBorderColor.equals(colors)) { return; } + + mBorderColor = + (colors != null) ? colors : ColorStateList.valueOf(RoundedDrawable.DEFAULT_BORDER_COLOR); + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + if (mBorderWidth > 0) { + invalidate(); + } + } + + /** + * Return true if this view should be oval and always set corner radii to half the height or + * width. + * + * @return if this {@link RoundedImageView} is set to oval. + */ + public boolean isOval() { + return mIsOval; + } + + /** + * Set if the drawable should ignore the corner radii set and always round the source to + * exactly half the height or width. + * + * @param oval if this {@link RoundedImageView} should be oval. + */ + public void setOval(boolean oval) { + mIsOval = oval; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + public Shader.TileMode getTileModeX() { + return mTileModeX; + } + + public void setTileModeX(Shader.TileMode tileModeX) { + if (this.mTileModeX == tileModeX) { return; } + + this.mTileModeX = tileModeX; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + public Shader.TileMode getTileModeY() { + return mTileModeY; + } + + public void setTileModeY(Shader.TileMode tileModeY) { + if (this.mTileModeY == tileModeY) { return; } + + this.mTileModeY = tileModeY; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + /** + * If {@code true}, we will also round the background drawable according to the settings on this + * ImageView. + * + * @return whether the background is mutated. + */ + public boolean mutatesBackground() { + return mMutateBackground; + } + + /** + * Set whether the {@link RoundedImageView} should round the background drawable according to + * the settings in addition to the source drawable. + * + * @param mutate true if this view should mutate the background drawable. + */ + public void mutateBackground(boolean mutate) { + if (mMutateBackground == mutate) { return; } + + mMutateBackground = mutate; + updateBackgroundDrawableAttrs(true); + invalidate(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt new file mode 100644 index 0000000..cb7c55f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt @@ -0,0 +1,64 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.expend.dsl.expandDp +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class CheckBoxButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + + private val viewSize = 20.dp + + var isChecked = false + + init { + context.withStyledAttributes(attrs, R.styleable.CheckBoxButton) { + isChecked = getBoolean(R.styleable.CheckBoxButton_radioCheck, false) + } + expandDp(14, 14) + viewChecked(isChecked) + } + + override fun setVisibility(visibility: Int) { + super.setVisibility(visibility) + if (isVisible) { + expandDp(14, 14) + } + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + if (enabled) { + viewChecked(isChecked) + } else { + setBackgroundResource(R.mipmap.icon_multi_checked_disabled) + } + } + + fun viewChecked(isChecked: Boolean) { + this.isChecked = isChecked + if (isChecked) { + setBackgroundResource(R.mipmap.icon_multi_checked) + } else { + setBackgroundResource(R.mipmap.checkbox_normal) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + setMeasuredDimension(widthMeasureSpec, heightMeasureSpec) + } else { + setMeasuredDimension(viewSize, viewSize) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt new file mode 100644 index 0000000..d171810 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt @@ -0,0 +1,191 @@ +package com.remax.visualnovel.widget.ui + + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.text.TextPaint +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp + + +/** + * Embed an icon into a Drawable that can be used as TextView icons, or ActionBar icons. + *

+ * new IconDrawable(context, IconValue.icon_star)
+ * .colorRes(R.color.white)
+ * .actionBarSize();
+
* + * If you don't set the size of the drawable, it will use the size + * that is given to him. Note that in an ActionBar, if you don't + * set the size explicitly it uses 0, so please use actionBarSize(). + */ +class IconFontDrawable : Drawable { + private var context: Context? = null + private var icon: String? = null + private var paint: TextPaint? = null + private var size = -1 + private var alphaValue = 255 + + /** + * Create an IconDrawable. + * + * @param context Your activity or application context. + * @param icon The icon key you want this drawable to display. + */ + constructor(context: Context, icon: String) { + init(context, icon) + } + + constructor(context: Context, @StringRes iconKey: Int) { + init(context, context.getString(iconKey)) + } + + private fun init(context: Context, icon: String) { + this.context = context + this.icon = icon + paint = TextPaint().apply { + typeface = context.getIconFontType() + style = Paint.Style.FILL + textAlign = Paint.Align.CENTER + isUnderlineText = false + color = Color.BLACK + isAntiAlias = true + } + } + + /** + * Set the size of the drawable. + * + * @param dimenRes The dimension resource. + * @return The current IconDrawable for chaining. + */ + fun sizeRes(dimenRes: Int): IconFontDrawable { + return sizePx(context!!.resources.getDimensionPixelSize(dimenRes)) + } + + /** + * Set the size of the drawable. + * + * @param size The size in density-independent pixels (dp). + * @return The current IconDrawable for chaining. + */ + fun sizeDp(size: Int): IconFontDrawable { + return sizePx(size.dp) + } + + /** + * Set the size of the drawable. + * + * @param size The size in pixels (px). + * @return The current IconDrawable for chaining. + */ + fun sizePx(size: Int): IconFontDrawable { + this.size = size + setBounds(0, 0, size, size) + invalidateSelf() + return this + } + + /** + * Set the color of the drawable. + * + * @param color The color, usually from android.graphics.Color or 0xFF012345. + * @return The current IconDrawable for chaining. + */ + fun color(color: Int): IconFontDrawable { + paint?.color = color + invalidateSelf() + return this + } + + /** + * Set the color of the drawable. + * + * @param colorRes The color resource, from your R file. + * @return The current IconDrawable for chaining. + */ + fun colorRes(colorRes: Int): IconFontDrawable { + paint?.color = ContextCompat.getColor(context!!, colorRes) + invalidateSelf() + return this + } + + /** + * Set the alpha of this drawable. + * + * @param alpha The alpha, between 0 (transparent) and 255 (opaque). + * @return The current IconDrawable for chaining. + */ + fun alpha(alpha: Int): IconFontDrawable { + setAlpha(alpha) + invalidateSelf() + return this + } + + override fun getIntrinsicHeight(): Int { + return size + } + + override fun getIntrinsicWidth(): Int { + return size + } + + override fun draw(canvas: Canvas) { + val bounds = bounds + val height = bounds.height() + paint?.textSize = height.toFloat() + val textBounds = Rect() + val textValue = icon.toString() + paint?.getTextBounds(textValue, 0, 1, textBounds) + val textHeight = textBounds.height() + val textBottom = bounds.top + (height - textHeight) / 2f + textHeight - textBounds.bottom + canvas.drawText(textValue, bounds.exactCenterX(), textBottom, paint!!) + } + + override fun isStateful(): Boolean { + return true + } + + override fun setState(stateSet: IntArray): Boolean { + val oldValue = paint?.alpha + val newValue = if (isEnabled(stateSet)) alphaValue else alphaValue / 2 + paint?.alpha = newValue + return oldValue != newValue + } + + override fun setAlpha(alpha: Int) { + alphaValue = alpha + paint?.alpha = alpha + } + + override fun setColorFilter(cf: ColorFilter?) { + paint?.colorFilter = cf + } + + override fun clearColorFilter() { + paint?.colorFilter = null + } + + @Deprecated("Deprecated in Java", ReplaceWith("alpha")) + override fun getOpacity(): Int { + return alpha + } + + /** + * Sets paint style. + * + * @param style to be applied + */ + fun setStyle(style: Paint.Style?) { + paint?.style = style + } + + // Util + private fun isEnabled(stateSet: IntArray): Boolean { + for (state in stateSet) if (state == android.R.attr.state_enabled) return true + return false + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt new file mode 100644 index 0000000..1d45e37 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt @@ -0,0 +1,114 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import androidx.core.content.withStyledAttributes + +/** + * Created by wl on 2022/9/6 + * IconFont控件 可用于icon+文字、纯icon、纯文字 + */ +open class IconFontTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + private var iconFontStart: String? = null //加在文字前面的icon + private var iconFontEnd: String? = null //文字后面icon + private var iconFontTop: String? = null + private var iconFontBottom: String? = null + + private var iconFontDrawablePadding: Int = 0 //drawable间距 px + private var iconFontSize: Int = 0 //iconFont的大小 + private var iconFontColorToken: String? = null //iconFont颜色 + + init { + context.withStyledAttributes(attrs, R.styleable.IconFontTextView) { + iconFontStart = getString(R.styleable.IconFontTextView_iconFontStart) + iconFontEnd = getString(R.styleable.IconFontTextView_iconFontEnd) + iconFontTop = getString(R.styleable.IconFontTextView_iconFontTop) + iconFontBottom = getString(R.styleable.IconFontTextView_iconFontBottom) + iconFontDrawablePadding = getDimension(R.styleable.IconFontTextView_iconFontDrawablePadding, 0f).toInt() + iconFontSize = getDimensionPixelOffset(R.styleable.IconFontTextView_iconFontSize, 0) + iconFontColorToken = getString(R.styleable.IconFontTextView_iconFontColorToken) + } + + setIconFontDrawable(iconFontStart, iconFontTop, iconFontEnd, iconFontBottom, iconSize = iconFontSize, isDp = false) + + transformationMethod = null + } + + /** + * 设置iconFont + * @param startIconFont 左图标 + * @param topIconFont 上图标 + * @param endIconFont 右图标 + * @param bottomIconFont 下图标 + * @param iconColorToken icon的color token + * @param iconSize icon的textSize + * @param iconPadding icon与文字的间距 + */ + fun setIconFontDrawable( + startIconFont: String? = null, + topIconFont: String? = null, + endIconFont: String? = null, + bottomIconFont: String? = null, + iconColorToken: String? = null, + iconSize: Int = 0, + isDp: Boolean = true, + iconPadding: Int? = null + ) { + var startDrawable: IconFontDrawable? = null + var endDrawable: IconFontDrawable? = null + var topDrawable: IconFontDrawable? = null + var bottomDrawable: IconFontDrawable? = null + if (!startIconFont.isNullOrEmpty()) { + startDrawable = IconFontDrawable(context, startIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (!endIconFont.isNullOrEmpty()) { + endDrawable = + IconFontDrawable(context, endIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (!topIconFont.isNullOrEmpty()) { + topDrawable = IconFontDrawable(context, topIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (!bottomIconFont.isNullOrEmpty()) { + bottomDrawable = IconFontDrawable(context, bottomIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (startIconFont == null && topIconFont == null && endIconFont == null && bottomIconFont == null) { + compoundDrawablePadding = 0 + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } else { + if (iconPadding != null) { + iconFontDrawablePadding = iconPadding.dp + compoundDrawablePadding = iconFontDrawablePadding + } else { + compoundDrawablePadding = iconFontDrawablePadding + } + setCompoundDrawablesRelative(startDrawable, topDrawable, endDrawable, bottomDrawable) + } + } + + private fun getFontColor(iconColorToken: String?): Int { + return if (iconFontColorToken.isNullOrBlank() && iconColorToken.isNullOrBlank()) { + currentTextColor + } else { + if (!iconColorToken.isNullOrBlank()) { + iconFontColorToken = iconColorToken + iconColorToken.handleUIToken(context)?.color ?: 0 + } else { + iconFontColorToken?.handleUIToken(context)?.color ?: 0 + } + } + } + + private fun getFontSize(iconSize: Int, isDp: Boolean): Int = if (iconSize == 0 && iconFontSize == 0) textSize.toInt() else { + if (iconSize != 0) { + if (isDp) iconSize.dp else iconSize + } else iconFontSize + } + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt new file mode 100644 index 0000000..df6a3f6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt @@ -0,0 +1,105 @@ +package com.remax.visualnovel.widget.ui + +import android.animation.Animator +import android.content.Context +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import androidx.core.view.isGone +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetItemLikeBinding +import com.remax.visualnovel.extension.changeLikedStatus +import com.remax.visualnovel.utils.EpalUtils +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.view.UITokenConstraintLayout +import com.dylanc.viewbinding.nonreflection.inflate + +/** + * Created by HJW on 2022/8/31 + * + */ +class LikeView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenConstraintLayout(context, attrs, defStyleAttr) { + + var binding: WidgetItemLikeBinding? = null + + companion object { + private const val DARK = 1 + private const val TRANSPARENT = 2 + } + + init { + binding = inflate(WidgetItemLikeBinding::inflate) + context.withStyledAttributes(attrs, R.styleable.LikeView) { + val bgType = getInt(R.styleable.LikeView_likeViewBg, DARK) + binding?.run { + root.changeBackground { + backgroundUIColorToken = context.getString( + when (bgType) { + TRANSPARENT -> { + R.string.color_transparent + } + + else -> { + R.string.color_surface_element_dark_normal + } + } + ) + + } + } + } + } + + fun changeLike(isLike: Boolean, isAnim: Boolean = false) { + binding?.run { + when { + !isLike -> { + widgetItemLottieLike.isVisible = false + widgetItemLikeIcon.changeLikedStatus(false) + } + + isAnim -> { + widgetItemLikeIcon.isGone = true + widgetItemLottieLike.run { + isVisible = true + progress = 0f + addAnimatorListener(object : Animator.AnimatorListener { + override fun onAnimationStart(p0: Animator) { + isEnabled = false + } + + override fun onAnimationEnd(p0: Animator) { + isEnabled = true + progress = 1f + } + + override fun onAnimationCancel(p0: Animator) { + + } + + override fun onAnimationRepeat(p0: Animator) { + + } + }) + playAnimation() + } + } + + else -> { + widgetItemLottieLike.isVisible = false + widgetItemLikeIcon.changeLikedStatus(true) + } + } + } + } + + fun setLikeNum(likedCount: Int?) { + setLikeNum(likedCount?.toLong()) + } + + fun setLikeNum(likedCount: Long?) { + binding?.widgetItemAlbumLikeNum?.text = EpalUtils.formatNumberAutoSize(likedCount ?: 0) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt new file mode 100644 index 0000000..3eae575 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt @@ -0,0 +1,47 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class RadioCheckButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + + private val viewSize = 20.dp + + var isChecked = false + private var normalDrawable = R.mipmap.radio_normal + + init { + context.withStyledAttributes(attrs, R.styleable.RadioCheckButton) { + isChecked = getBoolean(R.styleable.RadioCheckButton_radioCheck, false) + normalDrawable = getResourceId(R.styleable.RadioCheckButton_radioNormalDrawable, R.mipmap.radio_normal) + } + viewChecked(isChecked) + } + + fun viewChecked(isChecked: Boolean) { + this.isChecked = isChecked + if (isChecked) { + setBackgroundResource(R.mipmap.icon_checked) + } else { + setBackgroundResource(normalDrawable) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + setMeasuredDimension(widthMeasureSpec, heightMeasureSpec) + } else { + setMeasuredDimension(viewSize, viewSize) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt new file mode 100644 index 0000000..b021fb4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt @@ -0,0 +1,61 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class RadioOnPicButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + var isChecked = false + + init { + context.withStyledAttributes(attrs, R.styleable.RadioOnPicButton) { + isChecked = getBoolean(R.styleable.RadioOnPicButton_radioCheck, false) + } + changeTextColor { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + } + changeTextFont { + textUITextToken = context.getString(R.string.txt_numMonotype_xs) + } + changeBackground { + radiusToken = context.getString(R.string.radius_pill) + } + viewChecked(isChecked) + height = 20.dp + width = 20.dp + gravity = Gravity.CENTER + } + + fun viewChecked(isChecked: Boolean) { + this.isChecked = isChecked + changeBackground { + backgroundUIColorToken = if (isChecked) { + context.getString(R.string.color_primary_normal) + } else { + context.getString(R.string.color_surface_element_dark_normal) + } + } + } + +// override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { +// super.onMeasure(widthMeasureSpec, heightMeasureSpec) +// if (View.MeasureSpec.getMode(widthMeasureSpec) == View.MeasureSpec.EXACTLY && View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.EXACTLY) { +// setMeasuredDimension(widthMeasureSpec, heightMeasureSpec) +// } else { +// setMeasuredDimension(viewSize, viewSize) +// } +// } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt new file mode 100644 index 0000000..2fe2a31 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt @@ -0,0 +1,182 @@ +package com.remax.visualnovel.widget.ui + + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.util.AttributeSet +import androidx.annotation.StringRes +import androidx.appcompat.widget.SwitchCompat +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken + + +/** + * Created by HJW on 2022/9/12 + */ +class SwitchView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.appcompat.R.attr.switchStyle +) : + SwitchCompat(context, attrs, defStyleAttr) { + + companion object { + const val LARGE = 0 + const val SMALL = 1 + const val XSMALL = 2 + } + + private var bgWid = 0 + private var bgHei = 0 + private var thumbSize = 0 + private var padding = 0 + private var strokeWidth = 0 + + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.SwitchView) + val type = ta.getInt(R.styleable.SwitchView_switchSize, SMALL) + val colorStyle = ta.getInt(R.styleable.SwitchView_switchColorStyle, 0) + ta.recycle() + + when (type) { + LARGE -> { + bgWid = 56.dp + bgHei = 32.dp + thumbSize = 24.dp + padding = 4.dp + strokeWidth = 4.dp + } + + SMALL -> { + bgWid = 40.dp + bgHei = 24.dp + thumbSize = 16.dp + padding = 4.dp + strokeWidth = 4.dp + } + + XSMALL -> { + bgWid = 28.dp + bgHei = 16.dp + thumbSize = 12.dp + padding = 2.dp + strokeWidth = 1.dp + } + } + + background = setTrackDrawable(true, colorStyle) + //轨道背景 + trackDrawable = setTrackDrawable(false, colorStyle) + //滑块背景 + thumbDrawable = setThumbDrawable(colorStyle) + + setPadding(padding, padding, padding, padding) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(bgWid, bgHei) + } + + fun setPressChanged(callback: (Boolean) -> Unit) { + setOnCheckedChangeListener { v, checked -> + if (v.isPressed) { + callback(checked) + } + } + } + + private fun setThumbDrawable(style: Int): Drawable { + val normal: Int + val checked: Int + if (style == 0) { + normal = R.string.color_surface_white_normal + checked = R.string.color_surface_white_normal + } else { + normal = R.string.color_txt_secondary_normal + checked = R.string.color_surface_white_normal + } + val normalDrawable = getThumbStateDrawable(normal) + //val pressDrawable = getThumbStateDrawable(R.string.color_surface_white_press, R.string.color_surface_element_press) + //val disabledDrawable = getThumbStateDrawable(R.string.color_surface_white_disabled, R.string.color_surface_element_normal) + val checkedDrawable = getThumbStateDrawable(checked) + + val resDrawable = StateListDrawable() + //resDrawable.addState(intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled), pressDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_checked, android.R.attr.state_enabled), checkedDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_enabled), normalDrawable) + //resDrawable.addState(intArrayOf(), disabledDrawable) + + return resDrawable + } + + private fun getThumbStateDrawable(@StringRes colorToken: Int): Drawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setSize(thumbSize, thumbSize) + val color = context.handleUIToken(colorToken)?.color ?: 0 + setColor(color) + } + } + + private fun getTrackStateDrawable( + @StringRes colorToken: Int, + @StringRes strokeColorToken: Int, + isBg: Boolean + ): Drawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setSize(if (isBg) bgWid else bgWid - 8.dp, if (isBg) bgHei else bgHei - 8.dp) + val radius = context.handleUIToken(R.string.radius_pill)?.size ?: 0f + cornerRadius = radius + val color = context.handleUIToken(colorToken)?.color ?: 0 + val strokeWidth = if (isBg) strokeWidth else 0 + val strokeColor = context.handleUIToken(strokeColorToken)?.color ?: 0 + setStroke(strokeWidth, strokeColor) + setColor(color) + } + } + + private fun setTrackDrawable(isBg: Boolean, style: Int): Drawable { + val normal: Int + val checked: Int + val stroke: Int + if (style == 0) { + if (isBg) { + normal = R.string.color_transparent + checked = R.string.color_primary_normal + stroke = R.string.color_surface_element_normal + } else { + normal = R.string.color_surface_element_normal + checked = R.string.color_transparent + stroke = R.string.color_transparent + } + } else { + if (isBg) { + normal = R.string.color_surface_top_normal + checked = R.string.color_primary_normal + stroke = R.string.color_outline_normal + } else { + normal = R.string.color_surface_top_normal + checked = R.string.color_primary_normal + stroke = R.string.color_transparent + } + } + val normalDrawable = getTrackStateDrawable(normal, stroke, isBg) + //val pressDrawable = getTrackStateDrawable(R.string.color_surface_element_press, isBg) + //val disabledDrawable = getTrackStateDrawable(R.string.color_surface_element_normal, isBg) + val checkedDrawable = getTrackStateDrawable(checked, checked, isBg) + + val resDrawable = StateListDrawable() + //resDrawable.addState(intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled), pressDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_checked, android.R.attr.state_enabled), checkedDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_enabled), normalDrawable) + //resDrawable.addState(intArrayOf(), disabledDrawable) + return resDrawable + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt new file mode 100644 index 0000000..0f27f50 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt @@ -0,0 +1,172 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.AppCompatImageView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetUserAvatarBinding +import com.remax.visualnovel.extension.glide.loadAndCircleCrop +import com.remax.visualnovel.extension.glide.loadCircleAvatar +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.imageviewer.viewer.ViewerTransitionHelper +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.view.UITokenConstraintLayout +import com.dylanc.viewbinding.nonreflection.inflate + + +/** + * Created by HJW on 2022/8/31 + * + */ +class UserAvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenConstraintLayout(context, attrs, defStyleAttr) { + + companion object { + const val XXL = 0 + const val XL = 1 + const val L = 2 + const val M = 3 + const val S = 4 + + const val BORDER_WHITE = 1 + const val BORDER_BLACK = 2 + } + + private var binding: WidgetUserAvatarBinding? = null + + private var avatarSize: Int = 0 + private var avatarFrameSize: Int = 0 + private var onlineStatusSize: Int = 0 + private var avatarSizeType: Int = 0 + + private val sizeXXL = 128 + private val sizeFrameXXL = 160 + private val sizeXL = 80 + private val sizeFrameXL = 104 + private val sizeL = 64 + private val sizeFrameL = 88 + private val sizeM = 48 + private val sizeFrameM = 64 + private val sizeS = 32 + private val sizeFrameS = 40 + + init { + binding = inflate(WidgetUserAvatarBinding::inflate) + val ta = context.obtainStyledAttributes(attrs, R.styleable.UserAvatarView) + val avatarBorder = ta.getBoolean(R.styleable.UserAvatarView_avatarBorder, false) + val avatarBorderType = ta.getInt(R.styleable.UserAvatarView_avatarBorderType, BORDER_WHITE) + avatarSize = ta.getDimensionPixelSize(R.styleable.UserAvatarView_avatarSize, 0) + avatarFrameSize = ta.getDimensionPixelSize(R.styleable.UserAvatarView_avatarFrameSize, 0) + onlineStatusSize = ta.getDimensionPixelSize(R.styleable.UserAvatarView_onlineStatusSize, 0) + avatarSizeType = ta.getInt(R.styleable.UserAvatarView_avatarSizeType, -1) + ta.recycle() + binding?.run { + var avatarBorderPadding = 0.dp + when (avatarSizeType) { + XXL -> { + avatarSize = sizeXXL.dp + avatarFrameSize = sizeFrameXXL.dp + onlineStatusSize = 24.dp + } + + XL -> { + avatarBorderPadding = 2.dp + avatarSize = sizeXL.dp + avatarFrameSize = sizeFrameXL.dp + onlineStatusSize = 16.dp + } + + L -> { + avatarSize = sizeL.dp + avatarFrameSize = sizeFrameL.dp + onlineStatusSize = 16.dp + } + + M -> { + avatarSize = sizeM.dp + avatarFrameSize = sizeFrameM.dp + onlineStatusSize = 8.dp + } + + S -> { + avatarBorderPadding = 1.dp + avatarSize = sizeS.dp + avatarFrameSize = sizeFrameS.dp + onlineStatusSize = 8.dp + } + } + if (avatarBorder) { + userAvatarIv.setPadding(avatarBorderPadding, avatarBorderPadding, avatarBorderPadding, avatarBorderPadding) + userAvatarIv.changeBackground { + when (avatarBorderType) { + BORDER_WHITE -> backgroundUIColorToken = context.getString(R.string.color_surface_white_normal) + BORDER_BLACK -> backgroundUIColorToken = context.getString(R.string.color_background_default) + } + } + } + userAvatarIv.setSize(avatarSize, avatarSize) + userAvatarFrameIv.setSize(avatarFrameSize, avatarFrameSize) + userAvatarOnlineStatus.setSize(onlineStatusSize, onlineStatusSize) + + root.setSize(avatarSize, avatarSize) + + clipChildren = false + clipToPadding = false + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(avatarSize, avatarSize) + (parent as? ViewGroup)?.clipChildren = false + (parent as? ViewGroup)?.clipToPadding = false + } + + fun getAvatarImageView(): AppCompatImageView? = binding?.userAvatarIv + + /** + * needViewer 能否点击放大 + */ + fun loadAvatar(avatarUrl: String?, clickInvoke: ((View) -> Unit)? = null) { + binding?.userAvatarIv?.let { + if (avatarUrl?.startsWith("http") == true){ + it.loadCircleAvatar(avatarUrl, avatarSize) + }else{ + it.loadAndCircleCrop(avatarUrl) + } + if (clickInvoke != null) { + ViewerTransitionHelper.put(it) + setOnClick(it) { + clickInvoke.invoke(it) + } + } + } + } + + fun loadImageResourceAvatar(resId: Int) { + binding?.userAvatarIv?.setImageResource(resId) + } + + fun setAvatarViewPadding(padding: Int) { + binding?.userAvatarIv?.setPadding(padding,padding,padding,padding) + } + +// /** +// * 隐藏/显示在线状态 +// */ +// fun onlineStatusIsShow(isShow: Boolean) { +// binding?.userAvatarOnlineStatus?.isVisible = isShow +// } + +// /** +// * 展示头像框 +// */ +// fun showAvatarFrame(avatarFrame: String?, withHost: Boolean = true) { +// binding?.userAvatarFrameIv?.showVipAvatar(avatarFrame, withHost) +// } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt new file mode 100644 index 0000000..7435000 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt @@ -0,0 +1,69 @@ +package com.remax.visualnovel.widget.ui.bannerindicator + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getTextFontTypeface +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.youth.banner.indicator.BaseIndicator +import kotlin.math.ceil + +/** + * Created by HJW on 2022/9/8 + */ +class NumberIndicator @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + BaseIndicator(context, attrs, defStyleAttr) { + + private val rectF = RectF() + + private val textFont = context.handleUIToken(R.string.txt_label_s)?.textFont + private val textColor = context.handleUIToken(R.string.color_txt_primary_normal)?.color ?: 0 + + private val bgColor = context.handleUIToken(R.string.color_surface_element_disabled)?.color ?: 0 + + private val radius = context.handleUIToken(R.string.radius_xs)?.size ?: 0f + + private val viewHeight = 24.dp.toFloat() + private var viewWidth = 0f + + init { + mPaint.run { + textFont?.let { + typeface = context.getTextFontTypeface(it.typeFace) + textSize = it.textFontSize + } + textAlign = Paint.Align.CENTER + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val count = config.indicatorSize + if (count <= 1) { + return + } + viewWidth = mPaint.measureText("$count/$count") + 16.dp + setMeasuredDimension(ceil(viewWidth).toInt(), ceil(viewHeight).toInt()) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val count = config.indicatorSize + if (count <= 1) { + return + } + val content = "${config.currentPosition + 1}/$count" + rectF.set(0f, 0f, viewWidth, viewHeight) + mPaint.color = bgColor + canvas.drawRoundRect(rectF, radius, radius, mPaint) + mPaint.color = textColor + val fontMetrics = mPaint.fontMetrics + val distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom + val baseline = rectF.centerY() + distance * 1.5f + canvas.drawText(content, rectF.centerX(), baseline, mPaint) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt new file mode 100644 index 0000000..4f75707 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt @@ -0,0 +1,82 @@ +package com.remax.visualnovel.widget.ui.bannerindicator + +import android.content.Context +import android.graphics.Canvas +import android.graphics.RectF +import android.util.AttributeSet +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.youth.banner.indicator.BaseIndicator +import kotlin.math.ceil + +/** + * Created by HJW on 2022/9/8 + */ +class RectangleIndicator @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + BaseIndicator(context, attrs, defStyleAttr) { + + companion object { + const val XSMALL = 1 + const val SMALL = 2 + } + + private val rectF = RectF() + + private val selectColor = context.handleUIToken(R.string.color_surface_white_normal)?.color ?: 0 + private val normalColor = context.handleUIToken(R.string.color_surface_white_disabled)?.color ?: 0 + + private val radius = context.handleUIToken(R.string.radius_pill)?.size ?: 0f + + private var singleWidth = 4.dp.toFloat() + private var viewHeight = 4.dp.toFloat() + private var viewSpace = 3.dp.toFloat() + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.RectangleIndicator) + val type = ta.getInt(R.styleable.RectangleIndicator_rectangleSize, XSMALL) + ta.recycle() + when (type) { + XSMALL -> { + singleWidth = 4.dp.toFloat() + viewHeight = 4.dp.toFloat() + viewSpace = 3.dp.toFloat() + } + + SMALL -> { + singleWidth = 8.dp.toFloat() + viewHeight = 8.dp.toFloat() + viewSpace = 4.dp.toFloat() + } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val count = config.indicatorSize + if (count <= 1) { + return + } + //间距*(总数-1)+默认宽度*(总数-1)+选中宽度 + val space = viewSpace * (count - 1) + val normal = singleWidth * count + val wid = ceil(space + normal).toInt() + setMeasuredDimension(wid, ceil(viewHeight).toInt()) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val count = config.indicatorSize + if (count <= 1) { + return + } + var left = 0f + for (i in 0 until count) { + mPaint.color = if (config.currentPosition == i) selectColor else normalColor + val indicatorWidth = singleWidth + rectF.set(left, 0f, left + indicatorWidth, viewHeight) + left += indicatorWidth + viewSpace + canvas.drawRoundRect(rectF, radius, radius, mPaint) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt new file mode 100644 index 0000000..0200111 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt @@ -0,0 +1,289 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import androidx.core.widget.TextViewCompat +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class ButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val DefaultButton_Primary = 0 + const val DefaultButton_Secondary = 1 + const val DefaultButton_Tertiary = 2 + const val DefaultButton_Destructive = 3 + const val ContrastButton_Primary = 4 + const val ContrastButton_Secondary = 5 + const val ContrastButton_Tertiary_Light = 6 + const val ContrastButton_Tertiary_Dark = 7 + const val GhostButton_Primary = 8 + const val GhostButton_Secondary = 9 + const val ContextButton_Subscribe = 10 + const val IconButton_Primary = 11 + const val IconButton_Tertiary = 12 + const val DefaultButton_VIP = 13 + + const val LARGE = 0 + const val SMALL = 1 + } + + private var sizeType = -1 + private var buttonName = -1 + + private var overrideAttr = false + + init { + context.withStyledAttributes(attrs, R.styleable.ButtonView) { + sizeType = getInt(R.styleable.ButtonView_buttonSizeType, -1) + buttonName = getInt(R.styleable.ButtonView_buttonName, -1) + overrideAttr = getBoolean(R.styleable.ButtonView_buttonOverrideAttr, false) + } + customViewToken.run { + radiusToken = context.getString(R.string.radius_pill) + } + setButtonStyle(sizeType, buttonName) + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + + runCatching { + val minSize = 10.dp + val maxSize = textSize.toInt() + if (minSize < maxSize) { + TextViewCompat.setAutoSizeTextTypeWithDefaults(this, TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM) + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(this, minSize, maxSize, 2, TypedValue.COMPLEX_UNIT_PX) + } + } + + } + + fun setButtonStyle(buttonName: Int) { + setButtonStyle(sizeType, buttonName) + } + + fun getMeasureText() = paint.measureText(text.toString()) + paddingStart + paddingEnd + + fun setButtonStyle(sizeType: Int, buttonName: Int) { + this.buttonName = buttonName + customViewToken.run { + val isIconFont = buttonName == IconButton_Primary || buttonName == IconButton_Tertiary + /** + * 设置size大小 + */ + when (sizeType) { + LARGE -> { + height = 48.dp + val padding = 24.dp + setPadding(padding, 0, padding, 0) + if (isIconFont) { + typeface = context.getIconFontType() + textSize = 24f + } else { + textUITextToken = context.getString(R.string.txt_label_l) + } + } + + SMALL -> { + height = 32.dp + val padding = 16.dp + setPadding(padding, 0, padding, 0) + if (minWidth == 0) { + minWidth = 60.dp + } + if (isIconFont) { + typeface = context.getIconFontType() + textSize = 20f + } else { + textUITextToken = context.getString(R.string.txt_label_s) + } + } + } + /** + * 设置state颜色/描边 + */ + when (buttonName) { + DefaultButton_VIP -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_context_vip_normal) + backgroundUIPressedColorToken = "" + backgroundUIHoveredColorToken = "" + backgroundUIDisabledColorToken = "" + } + + DefaultButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_gradient_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_txt_primary_normal) + backgroundUIDisabledColorToken = context.getString(R.string.color_primary_disabled) + } + + DefaultButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + textUIHintColorToken = context.getString(R.string.color_primary_variant_hover) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_transparent) + backgroundUIHoveredColorToken = context.getString(R.string.color_transparent) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + + strokeUIWidthToken = context.getString(R.string.border_m) + strokeUIColorToken = context.getString(R.string.color_primary_variant_normal) + strokeUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + strokeUIHoveredColorToken = context.getString(R.string.color_primary_variant_hover) + strokeUIDisabledColorToken = context.getString(R.string.color_primary_variant_disabled) + } + + DefaultButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + + DefaultButton_Destructive -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_important_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_important_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_important_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_important_disabled) + } + + ContrastButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_base_specialmap_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_base_specialmap_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_base_specialmap_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_base_specialmap_disabled) + } + + ContrastButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_surface_white_normal) + textUIPressedColorToken = context.getString(R.string.color_surface_white_press) + textUIHintColorToken = context.getString(R.string.color_surface_white_hover) + textUIDisabledColorToken = context.getString(R.string.color_surface_white_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_transparent) + backgroundUIHoveredColorToken = context.getString(R.string.color_transparent) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + + strokeUIWidthToken = context.getString(R.string.border_m) + strokeUIColorToken = context.getString(R.string.color_surface_white_normal) + strokeUIPressedColorToken = context.getString(R.string.color_surface_white_press) + strokeUIHoveredColorToken = context.getString(R.string.color_surface_white_hover) + strokeUIDisabledColorToken = context.getString(R.string.color_surface_white_disabled) + } + + ContrastButton_Tertiary_Light -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_light_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_light_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_light_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_light_disabled) + } + + ContrastButton_Tertiary_Dark -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_dark_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_dark_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_dark_disabled) + } + + GhostButton_Primary -> { + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + GhostButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + ContextButton_Subscribe -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_context_subscribe_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_context_subscribe_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_context_subscribe_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_context_subscribe_disabled) + } + + IconButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_primary_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_primary_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_primary_disabled) + } + + IconButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + } + if (overrideAttr) { + obtainStyledAttributes() + } + if (!isIconFont) { + changeTextFont(this) + } + changeTextColor(this) + changeBackground(this) + changeOutline(this) + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt new file mode 100644 index 0000000..6e95dde --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt @@ -0,0 +1,46 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +/** + * Created by HJW on 2022/8/31 + * + */ +class FloatActionButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + init { + textSize = 24f + + val clickExpend = 24.dp + expand(clickExpend, clickExpend) + + customViewToken.apply { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_gradient_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_primary_gradient_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_primary_gradient_disabled) + radiusToken = context.getString(R.string.radius_pill) + } + + changeBackground(customViewToken) + changeTextColor(customViewToken) + + gravity = Gravity.CENTER + typeface = context.getIconFontType() + + height = 56.dp + width = 56.dp + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt new file mode 100644 index 0000000..2d67657 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt @@ -0,0 +1,275 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import androidx.core.content.withStyledAttributes +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +/** + * Created by HJW on 2022/8/31 + * + */ +class IconButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val DefaultIconButton_Primary = 0 + const val DefaultIconButton_Secondary = 1 + const val DefaultIconButton_Tertiary = 2 + const val GhostIconButton = 3 + const val GhostIconButton_Secondary = 4 + const val ContrastButton_Secondary = 5 + const val ContrastButton_Tertiary_Dark = 6 + const val ContrastButton_Tertiary_Light = 7 + const val NavButton = 8 + const val NavButton_OnPic = 9 + + const val LARGE = 0 + const val MEDIUM = 1 + const val SMALL = 2 + const val XSMALL = 3 + const val XXSMALL = 4 + + const val RECTANGLE = 0 + const val ROUND = 1 + } + + private var sizeType = -1 + private var radiusType = -1 + private var buttonName = -1 + + private var overrideAttr = false + + init { + context.withStyledAttributes(attrs, R.styleable.IconButtonView) { + sizeType = getInt(R.styleable.IconButtonView_iconButtonSizeType, -1) + radiusType = getInt(R.styleable.IconButtonView_iconButtonRadius, -1) + buttonName = getInt(R.styleable.IconButtonView_iconButtonName, -1) + overrideAttr = getBoolean(R.styleable.IconButtonView_buttonOverrideAttr, false) + } + gravity = Gravity.CENTER + typeface = context.getIconFontType() + setButtonStyle(sizeType, buttonName) + } + + fun setButtonStyle(sizeType: Int = this.sizeType, buttonName: Int, radiusType: Int = this.radiusType) { + customViewToken.run { + var isSecondary = false + var isNav = false + + when (radiusType) { + RECTANGLE -> { + radiusToken = context.getString(R.string.radius_s) + } + + ROUND -> { + radiusToken = context.getString(R.string.radius_pill) + } + } + + when (buttonName) { + DefaultIconButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_gradient_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_primary_gradient_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + + DefaultIconButton_Secondary -> { + isSecondary = true + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + textUIHoveredColorToken = context.getString(R.string.color_primary_variant_hover) + textUIDisabledColorToken = context.getString(R.string.color_primary_variant_disabled) + } + + DefaultIconButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + + GhostIconButton -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + GhostIconButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_txt_secondary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_secondary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + ContrastButton_Secondary -> { + isSecondary = true + textUIColorToken = context.getString(R.string.color_surface_white_normal) + textUIPressedColorToken = context.getString(R.string.color_surface_white_press) + textUIHoveredColorToken = context.getString(R.string.color_surface_white_hover) + textUIDisabledColorToken = context.getString(R.string.color_surface_white_disabled) + } + + ContrastButton_Tertiary_Dark -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_dark_press) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_dark_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_dark_disabled) + } + + ContrastButton_Tertiary_Light -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_light_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_light_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_light_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_light_disabled) + } + + NavButton -> { + isNav = true + radiusToken = context.getString(R.string.radius_pill) + + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + NavButton_OnPic -> { + isNav = true + radiusToken = context.getString(R.string.radius_pill) + + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_dark_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_dark_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_dark_disabled) + } + } + + when (sizeType) { + LARGE -> { + textSize = if (isSecondary) 48f else 24f +// height = if (isSecondary) 48.dp else 40.dp +// width = if (isSecondary) 48.dp else 40.dp + height = 48.dp + width = 48.dp + + val expendSize = when { + isSecondary -> { + 0 + } + + isNav -> { + 2.dp + } + + else -> { + 4.dp + } + } + xClickExpend = expendSize + yClickExpend = expendSize + } + + MEDIUM -> { + textSize = if (isSecondary) 36f else 20f + height = 36.dp + width = 36.dp + val expendSize = when { + isNav -> { + 4.dp + } + + else -> { + 6.dp + } + } + xClickExpend = expendSize + yClickExpend = expendSize + + } + + SMALL -> { + textSize = if (isSecondary) 32f else 16f + height = 32.dp + width = 32.dp + val expendSize = when { + isNav -> { + 6.dp + } + + else -> { + 8.dp + } + } + xClickExpend = expendSize + yClickExpend = expendSize + } + + XSMALL -> { + textSize = if (isSecondary) 24f else 12f + height = 24.dp + width = 24.dp + val expendSize = 10.dp + xClickExpend = expendSize + yClickExpend = expendSize + } + + XXSMALL -> { + textSize = if (isSecondary) 16f else 8f + height = 16.dp + width = 16.dp + val expendSize = 14.dp + xClickExpend = expendSize + yClickExpend = expendSize + } + } + + if (overrideAttr) { + obtainStyledAttributes() + } + + changeTextFont(this) + changeTextColor(this) + changeBackground(this) + changeOutline(this) + if (xClickExpend != 0 || yClickExpend != 0) { + expand(xClickExpend, yClickExpend) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt new file mode 100644 index 0000000..13479aa --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt @@ -0,0 +1,91 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.graphics.Paint +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +/** + * Created by HJW on 2022/8/31 + * + */ +class TextButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val TextButton_Primary = 0 + const val TextButton_Secondary = 1 + const val TextButton_Tertiary = 2 + + const val LARGE = 0 + const val MEDIUM = 1 + const val SMALL = 2 + } + + private var sizeType = -1 + private var buttonName = -1 + + init { + context.withStyledAttributes(attrs, R.styleable.TextButtonView) { + sizeType = getInt(R.styleable.TextButtonView_textButtonSizeType, -1) + buttonName = getInt(R.styleable.TextButtonView_textButtonName, -1) + val isUnderLine = getBoolean(R.styleable.TextButtonView_textButtonUnderLine,false) + if (isUnderLine){ + paint.flags = Paint.UNDERLINE_TEXT_FLAG + paint.isAntiAlias = true + } + } + setButtonStyle(sizeType, buttonName) + } + + fun setButtonStyle(sizeType: Int, buttonName: Int) { + customViewToken.run { + when (sizeType) { + LARGE -> { + textUITextToken = context.getString(R.string.txt_label_l) + } + + MEDIUM -> { + textUITextToken = context.getString(R.string.txt_label_m) + } + + SMALL -> { + textUITextToken = context.getString(R.string.txt_label_s) + } + } + + when (buttonName) { + TextButton_Primary -> { + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + textUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + textUIHintColorToken = context.getString(R.string.color_primary_variant_hover) + } + + TextButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIPressedColorToken = context.getString(R.string.color_txt_primary_press) + textUIHintColorToken = context.getString(R.string.color_txt_primary_hover) + } + + TextButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_secondary_normal) + textUIPressedColorToken = context.getString(R.string.color_txt_secondary_press) + textUIHintColorToken = context.getString(R.string.color_txt_secondary_hover) + } + } + changeTextFont(this) + changeTextColor(this) + changeOutline(this) + if (xClickExpend != 0 || yClickExpend != 0) { + expand(xClickExpend, yClickExpend) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt new file mode 100644 index 0000000..1d01253 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt @@ -0,0 +1,50 @@ +package com.remax.visualnovel.widget.ui.lock + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ButtonVipWithIconfontBinding +import com.remax.visualnovel.databinding.ButtonWithIconBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.uitoken.changeBackground + +/** + * Created by HJW on 2025/8/15 + */ + +fun ButtonWithIconBinding.setTxtAndClick( + content: String?, + @DrawableRes iconRes: Int = R.mipmap.icon_diamond, + isEnabled:Boolean = true, + callback: () -> Unit +) { + buttonText.text = content + buttonText.isEnabled = isEnabled + + buttonIcon.isVisible = iconRes != 0 + if (iconRes != 0) { + buttonIcon.setImageResource(iconRes) + } + + button.isEnabled = isEnabled + setOnClick(button) { + callback() + } +} + +fun ButtonVipWithIconfontBinding.setTxtAndClick( + content: String?, + @StringRes radiusRes: Int? = null, + callback: () -> Unit +) { + vipText.text = content + if (radiusRes != null) { + vipBtn.changeBackground { + radiusToken = root.context.getString(radiusRes!!) + } + } + setOnClick(vipBtn) { + callback() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt new file mode 100644 index 0000000..961494a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt @@ -0,0 +1,95 @@ +package com.remax.visualnovel.widget.ui.lock + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import com.remax.visualnovel.R +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +fun String?.getLockLabel() = if (LoginManager.isMyself(this)) LockTagView.PRIVATE_LABEL else LockTagView.PUBLIC_LABEL + +/** + * Created by HJW on 2022/8/31 + * + */ +class LockTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val ALL = 1 + const val LTRB = 2 + + const val PUBLIC_LABEL = 1 + const val PRIVATE_LABEL = 2 + } + + private var lockType = ALL + private var lockLabel = PUBLIC_LABEL + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LockTagView) + val type = typedArray.getInt(R.styleable.LockTagView_lockTagType, ALL) + val label = typedArray.getInt(R.styleable.LockTagView_lockTagLabel, PUBLIC_LABEL) + typedArray.recycle() + setRadiusType(type) + setLockLabel(label) + + textSize = 12f + val padding = 6.dp + setPadding(padding, padding, padding, padding) + gravity = Gravity.CENTER + changeTextFont() { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + onlyIconFont = true + } + } + + fun setLockLabel(lockLabel: Int) { + this.lockLabel = lockLabel + when (this@LockTagView.lockLabel) { + PUBLIC_LABEL -> { + setText(R.string.icon_public) + changeBackground { + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + } + } + + PRIVATE_LABEL -> { + setText(R.string.icon_private) + changeBackground { + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + } + } + } + } + + private fun setRadiusType(lockType: Int) { + this.lockType = lockType + when (this@LockTagView.lockType) { + ALL -> { + changeBackground { + val radius = context.getString(R.string.radius_xs) + radiusToken = radius + topRightRadiusToken = radius + topLeftRadiusToken = radius + bottomRightRadiusToken = radius + bottomLeftRadiusToken = radius + } + } + + LTRB -> { + changeBackground { + radiusToken = "" + val radius = context.getString(R.string.radius_l) + bottomRightRadiusToken = radius + topLeftRadiusToken = radius + } + } + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt new file mode 100644 index 0000000..672a68e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt @@ -0,0 +1,112 @@ +package com.remax.visualnovel.widget.ui.lock + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetLockTagBinding +import com.remax.visualnovel.extension.formatPrice +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout +import com.dylanc.viewbinding.nonreflection.inflate + +/** + * Created by HJW on 2022/8/31 + * + */ +class LockViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenLinearLayout(context, attrs, defStyleAttr) { + + private var binding: WidgetLockTagBinding? = null + + private val small = 1 + private val large = 2 + + init { + binding = inflate(WidgetLockTagBinding::inflate) + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LockViewGroup) + val radiusToken = typedArray.getString(R.styleable.LockViewGroup_radiusToken) ?: "" + val size = typedArray.getInt(R.styleable.LockViewGroup_lockViewGroupSize, small) + typedArray.recycle() + binding?.run { + group.changeBackground { + this.radiusToken = radiusToken + } + setSize(size) + } + } + + fun setSize(size: Int) { + binding?.run { + when (size) { + small -> { + unlockGroup.isVisible = false + with(lockIv) { + textSize = 24f + updateLayoutParams { + bottomToTop = R.id.lockTv + bottomToBottom = ConstraintLayout.LayoutParams.UNSET + } + setMargin(bottomMargin = 6.dp) + } + with(lockTv) { + isVisible = true + setSizeType(R.string.txt_label_m, 16.dp) + setMargin(bottomMargin = 0, topMargin = 6.dp) + updateLayoutParams { + topToBottom = R.id.lockIv + bottomToTop = ConstraintLayout.LayoutParams.UNSET + } + } + } + + large -> { + unlockGroup.isVisible = true + with(lockIv) { + textSize = 40f + updateLayoutParams { + bottomToTop = ConstraintLayout.LayoutParams.UNSET + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + } + setMargin(bottomMargin = 0.dp) + } + with(lockTv) { + isVisible = false + setSizeType(R.string.txt_label_l, 16.dp) + setMargin(bottomMargin = 24.dp, topMargin = 0) + updateLayoutParams { + topToBottom = ConstraintLayout.LayoutParams.UNSET + bottomToTop = R.id.unlockGroup + } + } + } + } + } + } + + /*fun setMyBalance() { + // 我的钱包余额 + binding?.unlockPriceView?.setPrice(formatPrice(WalletManager.balance)) + }*/ + + @SuppressLint("SetTextI18n") + fun setPreviewUnlockInfo(unlockPrice: Long?, clickUnlock: (() -> Unit)? = null) { + binding?.let { + val priceTxt = "${formatPrice(unlockPrice)} ${context.getString(R.string.unlock)}" + it.lockTv.setPrice(priceTxt) + it.buttonIcon.setTxtAndClick(priceTxt) { + LoginManager.checkLogin { + clickUnlock?.invoke() + } + } + } + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt new file mode 100644 index 0000000..a105c82 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt @@ -0,0 +1,105 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.tags.constant.TagSize +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class ColorSupportTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val DEFAULT = 0 + const val PRIMARY = 1 + const val WARNING = 2 + const val POSITIVE = 3 + const val EMPHASIS = 4 + const val IMPORTANT = 5 + } + + private var sizeType = TagSize.SMALL + + init { + includeFontPadding = false + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ColorSupportTagView) + sizeType = typedArray.getInt(R.styleable.ColorSupportTagView_tagViewSize, TagSize.SMALL) + val colorType = typedArray.getInt(R.styleable.ColorSupportTagView_tagBackgroundColorType, -1) + val background = typedArray.getString(R.styleable.ColorSupportTagView_tagBackgroundColorToken) ?: "" + typedArray.recycle() + val padding = 8.dp + setPadding(padding, 0, padding, 0) + setTagType(sizeType, background, colorType) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + gravity = Gravity.CENTER + } + + fun setTagType(type: Int = sizeType, colorToken: String = "", colorType: Int = -1) { + var textUITextToken = "" + when (type) { + TagSize.SMALL -> { + textUITextToken = context.getString(R.string.txt_label_s) + height = 24.dp + } + + TagSize.LARGE -> { + textUITextToken = context.getString(R.string.txt_label_m) + height = 32.dp + } + } + + val token = CustomViewToken( + textUITextToken = textUITextToken, + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal), + radiusToken = context.getString(R.string.radius_xs) + ) + val backgroundUIColorToken = when (colorType) { + DEFAULT -> { + token.textUIColorToken = context.getString(R.string.color_txt_primary_normal) + context.getString(R.string.color_surface_element_normal) + } + + PRIMARY -> { + context.getString(R.string.color_primary_normal) + } + + WARNING -> { + context.getString(R.string.color_warning_normal) + } + + POSITIVE -> { + context.getString(R.string.color_positive_normal) + } + + EMPHASIS -> { + context.getString(R.string.color_emphasis_normal) + } + + IMPORTANT -> { + context.getString(R.string.color_important_normal) + } + + else -> { + colorToken + } + } + token.backgroundUIColorToken = backgroundUIColorToken + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt new file mode 100644 index 0000000..98c38d2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt @@ -0,0 +1,88 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class ContextTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val PRICE_PROMOTION = 1 + const val SUBSCRIBE = 2 + const val LEGENDS = 3 + const val LEGENDS_B = 4 + } + + init { + includeFontPadding = false + gravity = Gravity.CENTER + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ContextTagView) + val type = typedArray.getInt(R.styleable.ContextTagView_contextTagType, PRICE_PROMOTION) + typedArray.recycle() + val padding = 8.dp + setPadding(padding, 0, padding, 0) + height = 24.dp + setTagType(type) + gravity = Gravity.CENTER_VERTICAL + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + + fun setTagType(type: Int) { + val token = CustomViewToken( + textUITextToken = context.getString(R.string.txt_label_s), + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal), + radiusToken = context.getString(R.string.radius_xs) + ) + val colorToken = when (type) { + PRICE_PROMOTION -> { + context.getString(R.string.color_primary_gradient_normal) + } + + SUBSCRIBE -> { + context.getString(R.string.color_context_subscribe_normal) + } + + LEGENDS -> { + token.apply { + strokeUIWidthToken = context.getString(R.string.border_s) + strokeUIColorToken = context.getString(R.string.color_context_legends_normal) + textUIColorToken = context.getString(R.string.color_context_legends_normal) + } + context.getString(R.string.color_surface_element_dark_normal) + } + + LEGENDS_B -> { + token.apply { + strokeUIWidthToken = context.getString(R.string.border_s) + strokeUIColorToken = context.getString(R.string.color_context_legends_variant_normal) + textUIColorToken = context.getString(R.string.color_context_legends_variant_normal) + } + context.getString(R.string.color_surface_element_dark_normal) + } + + else -> { + context.getString(R.string.color_transparent) + } + } + token.backgroundUIColorToken = colorToken + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt new file mode 100644 index 0000000..9d16b08 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt @@ -0,0 +1,73 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class OfficialTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val OFFICIAL = 1 + const val HOST = 2 + const val VIP_SERVICE = 3 + } + + init { + includeFontPadding = false + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.OfficialTagView) + val type = typedArray.getInt(R.styleable.OfficialTagView_officialTagType, OFFICIAL) + typedArray.recycle() + val padding = 8.dp + setPaddingRelative(padding, 0, padding, 0) + height = 24.dp + setTagType(type) + gravity = Gravity.CENTER_VERTICAL + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + + fun setTagType(type: Int) { + var colorToken = "" + var textUIColorToken = "" + when (type) { + OFFICIAL -> { + colorToken = context.getString(R.string.color_primary_normal) + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + } + + HOST -> { + colorToken = context.getString(R.string.color_primary_normal) + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + } + + VIP_SERVICE -> { + colorToken = context.getString(R.string.color_context_legends_normal) + textUIColorToken = context.getString(R.string.color_background_default) + } + } + val token = CustomViewToken( + textUITextToken = context.getString(R.string.txt_label_s), + textUIColorToken = textUIColorToken, + radiusToken = context.getString(R.string.radius_xs), + backgroundUIColorToken = colorToken, + ) + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt new file mode 100644 index 0000000..ed508c3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt @@ -0,0 +1,86 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.tags.constant.TagSize +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class PrimaryTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val DEFAULT = 1 + const val WHITE_ON_COLOR = 2 + const val BLACK_ON_COLOR = 3 + } + + private var sizeType = TagSize.SMALL + private var tagType = DEFAULT + + init { + includeFontPadding = false + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.PrimaryTagView) + val size = typedArray.getInt(R.styleable.PrimaryTagView_tagViewSize, TagSize.SMALL) + val type = typedArray.getInt(R.styleable.PrimaryTagView_primaryTagType, DEFAULT) + typedArray.recycle() + val padding = 8.dp + setPadding(padding, 0, padding, 0) + ellipsize = TextUtils.TruncateAt.END + maxLines = 1 + setTagType(type, size) + gravity = Gravity.CENTER + } + + fun setTagType(type: Int = tagType, size: Int = sizeType) { + tagType = type + sizeType = size + var colorToken = "" + + when (sizeType) { + TagSize.SMALL -> { + height = 24.dp + } + + TagSize.LARGE -> { + height = 32.dp + } + } + + when (type) { + DEFAULT -> { + colorToken = context.getString(R.string.color_surface_element_normal) + } + + WHITE_ON_COLOR -> { + colorToken = context.getString(R.string.color_surface_element_light_normal) + } + + BLACK_ON_COLOR -> { + colorToken = context.getString(R.string.color_surface_element_dark_normal) + } + } + val token = CustomViewToken( + textUITextToken = context.getString(if (sizeType == TagSize.SMALL) R.string.txt_label_s else R.string.txt_label_m), + textUIColorToken = context.getString(R.string.color_txt_primary_normal), + radiusToken = context.getString(R.string.radius_xs), + backgroundUIColorToken = colorToken, + ) + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt new file mode 100644 index 0000000..57e2211 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.widget.ui.tags.constant + +/** + * Created by HJW on 2023/7/27 + */ +class TagSize { + companion object { + const val LARGE = 1 + const val SMALL = 2 + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt new file mode 100644 index 0000000..c97c818 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt @@ -0,0 +1,493 @@ +package com.remax.visualnovel.widget.uitoken + +import android.content.res.ColorStateList +import android.graphics.LinearGradient +import android.graphics.Outline +import android.graphics.Shader +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.StringRes +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.extension.getTextFontTypeface +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.bean.UIToken +import com.remax.visualnovel.widget.uitoken.view.UITokenEditView +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import com.remax.visualnovel.widget.uitoken.view.UIView +import timber.log.Timber + +/** + * Created by HJW on 2022/9/1 + */ + +/** + * 修改文字字体 + */ +fun TextView.changeTextFont(customViewToken: CustomViewToken) { + if (customViewToken.onlyIconFont) { + typeface = context.getIconFontType() + } else { + setTokenFont(customViewToken.textUITextToken) + } +} + +/** + * 修改文字字体和颜色 + */ +fun TextView.changeTextStyle(block: CustomViewToken.() -> Unit) { + changeTextFont(block) + changeTextColor(block) +} + +/** + * 修改文字字体 + */ +fun TextView.changeTextFont(block: CustomViewToken.() -> Unit) { + when (this) { + is UITokenTextView -> { + changeTextFont(getUITokenView().apply(block)) + } + + is UITokenEditView -> { + changeTextFont(getUITokenView().apply(block)) + } + + else -> { + val uiToken = CustomViewToken() + changeTextFont(uiToken.apply(block)) + } + } +} + +/** + * 修改文字颜色 + */ +fun TextView.changeTextColor(block: CustomViewToken.() -> Unit) { + when (this) { + is UITokenTextView -> { + changeTextColor(getUITokenView().apply(block)) + } + + is UITokenEditView -> { + changeTextColor(getUITokenView().apply(block)) + } + + else -> { + val uiToken = CustomViewToken() + changeTextColor(uiToken.apply(block)) + } + } +} + +/** + * 修改文字颜色 + */ +fun TextView.changeTextColor(@StringRes textUIColorToken: Int) { + changeTextColor { + this.textUIColorToken = context.getString(textUIColorToken) + } +} + +/** + * 修改文字颜色 + */ +fun TextView.changeTextColor(customViewToken: CustomViewToken) { + customViewToken.run { + setTokenColor( + textUIColorToken, + textUIPressedColorToken, + textUIHoveredColorToken, + textUIDisabledColorToken, + textUIHintColorToken, + textUILinkColorToken + ) + } +} + +/** + * 修改阴影 + */ +fun View.changeOutline(customViewToken: CustomViewToken) { + if (customViewToken.outlineToken.isEmpty()) return + customViewToken.outlineToken.handleUIToken(context)?.outLine?.let { out -> + val radius = customViewToken.radiusToken.handleUIToken(context)?.size ?: 0f + clipToOutline = false + elevation = out.elevation + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.alpha = out.alpha + outline.setRoundRect(0, 0, view.width, view.height, radius) + if (out.offsetX != 0 || out.offsetY != 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + outlineSpotShadowColor = out.color + } + outline.offset(out.offsetX, out.offsetY) + } + } + } + } +} + +/** + * 重新覆盖圆角时,block分别设置4个角的,因为4个角已经被上一次的总radius赋值了,不会走ifEmpty了 + * 修改背景色 + */ +fun View.changeBackground(block: CustomViewToken.() -> Unit) { + when (this) { + is UIView -> { + changeBackground(this.getUITokenView().apply(block)) + } + + else -> { + val uiToken = CustomViewToken() + changeBackground(uiToken.apply(block)) + } + } +} + +/** + * 修改背景色&圆角 + */ +fun View.changeBackground( + @StringRes backgroundUIColorToken: Int, + @StringRes radiusToken: Int = 0, + @StringRes topLeftRadiusToken: Int = radiusToken, + @StringRes topRightRadiusToken: Int = radiusToken, + @StringRes bottomRightRadiusToken: Int = radiusToken, + @StringRes bottomLeftRadiusToken: Int = radiusToken +) { + changeBackground( + CustomViewToken( + backgroundUIColorToken = context.getString(backgroundUIColorToken), + topLeftRadiusToken = if (topLeftRadiusToken == 0) "" else context.getString(topLeftRadiusToken), + topRightRadiusToken = if (topRightRadiusToken == 0) "" else context.getString(topRightRadiusToken), + bottomRightRadiusToken = if (bottomRightRadiusToken == 0) "" else context.getString(bottomRightRadiusToken), + bottomLeftRadiusToken = if (bottomLeftRadiusToken == 0) "" else context.getString(bottomLeftRadiusToken), + ) + ) +} + +/** + * 修改背景色 + */ +fun View.changeBackground(customViewToken: CustomViewToken) { + customViewToken.run { + setTokenBgColor( + normalToken = backgroundUIColorToken, + pressedToken = backgroundUIPressedColorToken, + hoveredToken = backgroundUIHoveredColorToken, + disabledToken = backgroundUIDisabledColorToken, + radiusToken = radiusToken, + topLeftRadiusToken = topLeftRadiusToken.ifEmpty { radiusToken }, + topRightRadiusToken = topRightRadiusToken.ifEmpty { radiusToken }, + bottomRightRadiusToken = bottomRightRadiusToken.ifEmpty { radiusToken }, + bottomLeftRadiusToken = bottomLeftRadiusToken.ifEmpty { radiusToken }, + strokeColorWidthToken = strokeUIWidthToken, + normalStrokeColorToken = strokeUIColorToken, + pressedStrokeColorToken = strokeUIPressedColorToken, + hoveredStrokeColorToken = strokeUIHoveredColorToken, + disabledStrokeColorToken = strokeUIDisabledColorToken, + dashWidth = strokeDashWidth, + dashGap = strokeDashGap, + ) + } +} + +/** + * 设置文字大小 + */ +private fun TextView.setTokenFont(textToken: String) { + if (textToken.isEmpty()) return + textToken.handleUIToken(this.context)?.textFont?.run { + Timber.d("${this@setTokenFont} setTokenFont : $this") + val myTypeface = context.getTextFontTypeface(typeFace) + //API 28才能直接设置字重和斜体 +// val tp = Typeface.create(myTypeface,100,true) val tp = Typeface.create(myTypeface,100,true) + typeface = myTypeface + if (textFontSize != 0f) { + setTextSize(TypedValue.COMPLEX_UNIT_PX, textFontSize) + } + if (textLineSpace != 0f) { + setLineSpacing(textLineSpace, 1f) + } + } +} + +/** + * textView 设置各种文字颜色 + */ +private fun TextView.setTokenColor( + normalToken: String, + pressedToken: String = "", + hoveredToken: String = "", + disabledToken: String = "", + hintToken: String = "", + linkToken: String = "", +) { + if (normalToken.isEmpty()) return + normalToken.handleUIToken(this.context)?.run { + when (type) { + UIToken.FILL_COLOR -> { + paint.shader = null + setTextColor(this.color) + } + + UIToken.LINEAR_GRADIENT -> { + val getText = text.toString() + val x0 = 0f + var x1 = 0f + val y0 = 0f + var y1 = 0f + when (deg) { + //从左到右 + resources.getString(R.string.glo_deg_ltr) -> { + x1 = paint.measureText(getText) + } + //从上到下 + resources.getString(R.string.glo_deg_ttb) -> { + y1 = textSize + } + //从左上到右下 + resources.getString(R.string.glo_deg_lttrb) -> { + x1 = paint.measureText(getText) + y1 = textSize + } + } + val gradient = LinearGradient( + x0, + y0, + x1, + y1, + colors, + null, + Shader.TileMode.CLAMP + ) + paint.shader = gradient + } + } + } + //设置hint颜色 + hintToken.handleUIToken(this.context)?.run { + if (this.color != 0) { + setHintTextColor(this.color) + } + } + //设置link颜色 + linkToken.handleUIToken(this.context)?.run { + if (this.color != 0) { + setLinkTextColor(this.color) + } + } + if (disabledToken.isNotEmpty() || hoveredToken.isNotEmpty() || pressedToken.isNotEmpty()) { + val disabledColor = disabledToken.handleUIToken(this.context)?.color ?: 0 + val hoveredColor = hoveredToken.handleUIToken(this.context)?.color ?: 0 + val pressColor = pressedToken.handleUIToken(this.context)?.color ?: 0 + val colorList = createColorStateList(disabledColor, hoveredColor, pressColor, currentTextColor) + setTextColor(colorList) + } +} + +private fun createColorStateList(disabled: Int, hovered: Int, pressed: Int, normal: Int): ColorStateList { + val colors = intArrayOf( + if (pressed != 0) pressed else normal, + if (hovered != 0) hovered else normal, + normal, + if (disabled != 0) disabled else normal + ) + val states = arrayOfNulls(4) + states[0] = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + states[1] = intArrayOf(android.R.attr.state_hovered, android.R.attr.state_enabled) + states[2] = intArrayOf(android.R.attr.state_enabled) + states[3] = intArrayOf() + return ColorStateList(states, colors) +} + + +/** + * 设置背景色 + */ +private fun View.setTokenBgColor( + normalToken: String, + pressedToken: String = "", + hoveredToken: String = "", + disabledToken: String = "", + radiusToken: String = "", + topLeftRadiusToken: String = radiusToken, + topRightRadiusToken: String = radiusToken, + bottomRightRadiusToken: String = radiusToken, + bottomLeftRadiusToken: String = radiusToken, + strokeColorWidthToken: String = "", + normalStrokeColorToken: String = "", + pressedStrokeColorToken: String = "", + hoveredStrokeColorToken: String = "", + disabledStrokeColorToken: String = "", + dashWidth: Float = 0f, + dashGap: Float = 0f, +) { + if (normalToken.isEmpty()) return + val resDrawable = StateListDrawable() + if (pressedToken.isNotEmpty()) { + getGradientDrawable( + pressedToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + pressedStrokeColorToken, + dashWidth, + dashGap + ).let { pressedDrawable -> + resDrawable.addState( + intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled), + pressedDrawable + ) + } + } + + if (hoveredToken.isNotEmpty()) { + getGradientDrawable( + hoveredToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + hoveredStrokeColorToken, + dashWidth, + dashGap + ).let { hoveredDrawable -> + if (this is EditText) { + resDrawable.addState( + intArrayOf(android.R.attr.state_focused, android.R.attr.state_enabled), + hoveredDrawable + ) + } + resDrawable.addState( + intArrayOf(android.R.attr.state_hovered, android.R.attr.state_enabled), + hoveredDrawable + ) + } + } + + val normalDrawable = getGradientDrawable( + normalToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + normalStrokeColorToken, + dashWidth, + dashGap + ) + resDrawable.addState(intArrayOf(android.R.attr.state_enabled), normalDrawable) + + if (disabledToken.isNotEmpty()) { + val disabledDrawable = getGradientDrawable( + disabledToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + disabledStrokeColorToken, + dashWidth, + dashGap + ) + resDrawable.addState(intArrayOf(), disabledDrawable) + } else { + resDrawable.addState(intArrayOf(), normalDrawable) + } + + + background = resDrawable +} + +fun getGradientDrawableOrientation(deg: String?): GradientDrawable.Orientation { + val resources = CommonApplicationProxy.application.resources + return when (deg) { + //从左到右 + resources.getString(R.string.glo_deg_ltr) -> { + GradientDrawable.Orientation.LEFT_RIGHT + } + //从上到下 + resources.getString(R.string.glo_deg_ttb) -> { + GradientDrawable.Orientation.TOP_BOTTOM + } + //从左上到右下 + resources.getString(R.string.glo_deg_lttrb) -> { + GradientDrawable.Orientation.TL_BR + } + + else -> { + GradientDrawable.Orientation.LEFT_RIGHT + } + } +} + +/** + * 获取带颜色的Drawable + */ +fun View.getGradientDrawable( + bgColorToken: String, + topLeftRadiusToken: String = "", + topRightRadiusToken: String = "", + bottomRightRadiusToken: String = "", + bottomLeftRadiusToken: String = "", + strokeWidthToken: String = "", + strokeColorToken: String = "", + dashWidth: Float = 0f, // 虚线的长度 + dashGap: Float = 0f, // 虚线的间距,0为实线 +): Drawable { + val gradientDrawable = GradientDrawable() + gradientDrawable.shape = GradientDrawable.RECTANGLE + bgColorToken.handleUIToken(this.context)?.run { + when (type) { + UIToken.FILL_COLOR -> { + gradientDrawable.setColor(color) + } + + UIToken.LINEAR_GRADIENT -> { + gradientDrawable.orientation = getGradientDrawableOrientation(deg) + gradientDrawable.colors = colors + } + } + } + val swToken = strokeWidthToken.handleUIToken(this.context) + if (swToken != null) { + if (strokeColorToken.isNotEmpty()) { + strokeColorToken.handleUIToken(this.context)?.let { scToken -> + gradientDrawable.setStroke(swToken.size.toInt(), scToken.color, dashWidth, dashGap) + } + } + } else { + gradientDrawable.setStroke(0, 0) + } + + val topLeftRadius = topLeftRadiusToken.handleUIToken(this.context)?.size ?: 0f + val topRightRadius = topRightRadiusToken.handleUIToken(this.context)?.size ?: 0f + val bottomRightRadius = bottomRightRadiusToken.handleUIToken(this.context)?.size ?: 0f + val bottomLeftRadius = bottomLeftRadiusToken.handleUIToken(this.context)?.size ?: 0f + //左上、右上、右下、左下的圆角半径 + gradientDrawable.cornerRadii = floatArrayOf( + topLeftRadius, + topLeftRadius, + topRightRadius, + topRightRadius, + bottomRightRadius, + bottomRightRadius, + bottomLeftRadius, + bottomLeftRadius + ) + return gradientDrawable +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt new file mode 100644 index 0000000..dbd51b0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt @@ -0,0 +1,234 @@ +package com.remax.visualnovel.widget.uitoken + +import android.content.Context +import android.graphics.Color +import android.os.Build +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.readJsonAsset +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.bean.UIToken +import org.json.JSONObject + +/** + * Created by HJW on 2022/8/31 + */ +private var jsonObject: JSONObject? = null +private const val packageName = BuildConfig.APPLICATION_ID +private val tokenMap = mutableMapOf() + +fun Context.handleUIToken(@StringRes res: Int): UIToken? { + return if (res == 0) null else getString(res).handleUIToken(this) +} + +fun String.handleUIToken(context: Context): UIToken? { + if (this.isEmpty()) return null + if (tokenMap.contains(this)) { + return tokenMap[this] + } + //本地token 透明色 + when (this) { + context.getString(R.string.color_transparent) -> { + val uiToken = UIToken(UIToken.FILL_COLOR, context.getColor(R.color.transparent)) + tokenMap[this] = uiToken + return uiToken + } + } + if (jsonObject == null) { + val jsonStr = context.readJsonAsset("uitoken/token_sys.json") + jsonObject = JSONObject(jsonStr) + } + // token是正确的key + if (jsonObject?.has(this) == true) { + val value = jsonObject?.getString(this) ?: "" + when { + //文字组合 $glo.font.family.sys,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48 + value.startsWith("\$glo.font") -> { + val txtTokens = value.split(",") + // [$glo.font.family.sys,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48] + val uiToken = UIToken(UIToken.FONT) + val uiFont = UIToken.TextFont() + var fontTypeFace = "" + txtTokens.forEachIndexed { index, s -> + val gloToken = s.replace("$", "").replace(".", "_").trim() + when (index) { + //字体 + 0 -> { + val stringId = context.resources.getIdentifier(gloToken, "string", packageName) + fontTypeFace += "${context.getString(stringId)}-" + } + //字号 + 1 -> { + val dimenId = context.resources.getIdentifier(gloToken, "dimen", packageName) + uiFont.textFontSize = context.resources.getDimension(dimenId) + } + //字重 + 2 -> { + val stringId = context.resources.getIdentifier(gloToken, "string", packageName) + fontTypeFace += context.getString(stringId) + } + //行间距 + 3 -> { + val dimenId = context.resources.getIdentifier(gloToken, "dimen", packageName) + uiFont.textLineSpace = context.resources.getDimension(dimenId) + } + } + } + uiFont.typeFace = "family/$fontTypeFace.ttf" + uiToken.textFont = uiFont + tokenMap[this] = uiToken + return uiToken + } + + //颜色 $glo.color.violet.40 + value.startsWith("\$glo.color") -> { + val uiToken = UIToken(UIToken.FILL_COLOR, value.translateColor(context)) + tokenMap[this] = uiToken + return uiToken + } + + // 渐变色 @GRA:$glo.deg.ltr,$glo.color.blue.40,$glo.color.violet.40 + value.startsWith("@GRA:") -> { + // [$glo.deg.ltr,$glo.color.blue.40&$glo.transparent.t0,$glo.color.violet.40] + val values = value.replace("@GRA:", "").split(",") + val uiToken = UIToken(UIToken.LINEAR_GRADIENT) + val colors = arrayListOf() + values.forEachIndexed { index, s -> + val idName = s.replace("$", "").replace(".", "_").trim() + when (index) { + //下标0是方向 + 0 -> { + val degId = context.resources.getIdentifier(idName, "string", packageName) + uiToken.deg = context.resources.getString(degId) + } + //其他都是颜色 + else -> { + //$glo.color.blue.40&$glo.transparent.t0 + val color = s.replace("&", ",").translateColor(context) + colors.add(color) + } + } + } + uiToken.colors = colors.toIntArray() + tokenMap[this] = uiToken + return uiToken + } + + // 引用其他颜色token + value.startsWith("\$color") -> { + return value.replace("$", "").handleUIToken(context) + } + + // 大小 圆角 $glo.radius.16 + value.startsWith("\$glo.radius") -> { + val uiToken = UIToken(UIToken.SIZE, size = value.translateDimenPixel(context)) + tokenMap[this] = uiToken + return uiToken + } + + // 大小 描边 $glo.border.2 + value.startsWith("\$glo.border") -> { + val uiToken = UIToken(UIToken.SIZE, size = value.translateDimenPixel(context)) + tokenMap[this] = uiToken + return uiToken + } + + //阴影 @SHA:$glo.color.black,$glo.transparent.t15,0&4,4 + value.startsWith("@SHA:") -> { + val outLine = UIToken.OutLine() + //[$glo.color.black,$glo.transparent.t15,0&16,16] + val valueSplit = value.replace("@SHA:", "").split(",") + valueSplit.forEachIndexed { index, s -> + when (index) { + //阴影颜色 + 0 -> { + outLine.color = s.translateColor(context) + } + //阴影透明度 + 1 -> { + val alphaRes = s.replace("$", "").replace(".", "_") + val float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val alphaId = context.resources.getIdentifier(alphaRes, "dimen", packageName) + context.resources.getFloat(alphaId) + } else { + val alphaId = context.resources.getIdentifier(alphaRes, "fraction", packageName) + context.resources.getFraction(alphaId, 1, 1) + } + outLine.alpha = float * 2 + } + //偏移 + 2 -> { + val offSetSplit = s.split("&") + outLine.offsetX = offSetSplit[0].toInt().dp + outLine.offsetY = offSetSplit[1].toInt().dp + } + //elevation + 3 -> { + outLine.elevation = s.toInt().dp.toFloat() + } + } + } + val uiToken = UIToken(UIToken.OUTLINE, outLine = outLine) + tokenMap[this] = uiToken + return uiToken + } + } + } + + return null +} + +/** + * 大小token返回px + */ +private fun String.translateDimenPixel(context: Context): Float { + val idName = this.replace("$", "").replace(".", "_").trim() + val dimenId = context.resources.getIdentifier(idName, "dimen", packageName) + return context.resources.getDimension(dimenId) +} + +/** + * 颜色token返回16进制 + */ +private fun String.translateColor(context: Context): Int { + val values = this.replace("$", "").replace(".", "_").split(",") + var colorInt = 0 + var alpha = 255 + values.forEachIndexed { index, s -> + val value = s.trim() + when (index) { + // 0 是颜色 + 0 -> { + val colorId = context.resources.getIdentifier(value, "color", packageName) + colorInt = ContextCompat.getColor(context, colorId) + } + // 1 是透明度 + 1 -> { + val float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val alphaId = context.resources.getIdentifier(value, "dimen", packageName) + context.resources.getFloat(alphaId) + } else { + val alphaId = context.resources.getIdentifier(value, "fraction", packageName) + context.resources.getFraction(alphaId, 1, 1) + } + alpha = (float * alpha).toInt() + } + } + } + val red: Int = colorInt and 0x00ff0000 shr 16 + val green: Int = colorInt and 0x0000ff00 shr 8 + val blue: Int = colorInt and 0x000000ff + return Color.argb(alpha, red, green, blue) +} + + +//将10进制颜色(int)值转换成16进制(String) +fun Int.intToString(): String { + var hexString = Integer.toHexString(this) + if (hexString.length == 1) { + hexString = "0$hexString" + } + return hexString +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt new file mode 100644 index 0000000..06b2722 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt @@ -0,0 +1,44 @@ +package com.remax.visualnovel.widget.uitoken.bean + +/** + * Created by HJW on 2022/9/1 + */ +data class CustomViewToken( + /** + * 文字相关 只在textView控件中有用 + */ + var textUITextToken: String = "", //edittext也可用 + var textUIColorToken: String = "",//edittext也可用 + var textUIPressedColorToken: String = "", + var textUIHoveredColorToken: String = "", + var textUIDisabledColorToken: String = "", + var textUIHintColorToken: String = "",//edittext也可用 + var textUILinkColorToken: String = "",//IM消息链接颜色 + /** + * 背景相关 全view可用 + */ + var backgroundUIColorToken: String = "", + var backgroundUIPressedColorToken: String = "", + var backgroundUIHoveredColorToken: String = "", + var backgroundUIDisabledColorToken: String = "", + var radiusToken: String = "", + var topLeftRadiusToken: String = "", + var topRightRadiusToken: String = "", + var bottomLeftRadiusToken: String = "", + var bottomRightRadiusToken: String = "", + var strokeUIWidthToken: String = "", + var strokeUIColorToken: String = "", + var strokeUIPressedColorToken: String = "", + var strokeUIHoveredColorToken: String = "", + var strokeUIDisabledColorToken: String = "", + var strokeDashWidth: Float = 0f, + var strokeDashGap: Float = 0f, + // 背景阴影 + var outlineToken: String = "", + //X Y方向的点击区域扩大 + var xClickExpend: Int = 0, + var yClickExpend: Int = 0, + var onlyIconFont: Boolean = false, + var fixTextIsSelectable: Boolean = false, + var underline: Boolean = false, +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt new file mode 100644 index 0000000..6b170fe --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt @@ -0,0 +1,55 @@ +package com.remax.visualnovel.widget.uitoken.bean + +/** + * Created by HJW on 2022/8/31 + */ +class UIToken( + var type: Int, + /** + * 颜色 type == FILL_COLOR + */ + var color: Int = 0, + + /** + * 渐变色 type == LINEAR_GRADIENT + * textview设置渐变色时,其他状态不会生效 + */ + var colors: IntArray = intArrayOf(), + var deg: String = "", + /** + * 大小单位px type == SIZE 圆角和描边使用 + */ + var size: Float = 0f, + /** + * 字体 type == FONT + */ + var textFont: TextFont? = null, + /** + * 阴影 type == OUTLINE + */ + var outLine: OutLine? = null +) { + companion object { + const val FILL_COLOR = 1 //全填充颜色 + const val LINEAR_GRADIENT = 2 //线性渐变 + const val SIZE = 3 //dimen大小 + const val FONT = 4 //字体 + const val OUTLINE = 5 //阴影 + } + + data class TextFont( + var typeFace: String = "", + var textFontSize: Float = 0f, //pix + var textLineSpace: Float = 0f //pix + ) + + data class OutLine( + var color: Int = 0, + var alpha: Float = 1.0f, + var elevation: Float = 0f, + var offsetX: Int = 0, + var offsetY: Int = 0 + ) + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt new file mode 100644 index 0000000..1874a8b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt @@ -0,0 +1,135 @@ +package com.remax.visualnovel.widget.uitoken.expend.dsl + +import android.annotation.SuppressLint +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.text.InputFilter +import android.view.MotionEvent +import android.view.TouchDelegate +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.TextView +import androidx.coordinatorlayout.widget.ViewGroupUtils +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import com.remax.visualnovel.utils.spannablex.utils.dp + +fun DialogFragment.fullScreenMode() { + dialog?.window?.apply { + attributes?.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + decorView.setPadding(0, 0, 0, 0) + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } +} + +inline var TextView.maxLength: Int + get() { + return 1 + } + set(value) { + /** + * 需要把已有的fitters规则一起添加上,否则就只剩下长度Filter了 + */ + val list = mutableListOf().apply { + filters.forEach { + if(it !is InputFilter.LengthFilter){ + add(it) + } + } + add(InputFilter.LengthFilter(value)) + } + filters = list.toTypedArray() + } + +@SuppressLint("RestrictedApi") +fun View.expandRound(left: Int = 0, top: Int = 0, right: Int = 0, bottom: Int = 0) { + class MultiTouchDelegate(bound: Rect? = null, delegateView: View) : TouchDelegate(bound, delegateView) { + val delegateViewMap = mutableMapOf() + private var delegateView: View? = null +// var delegateParentViewMap = mutableMapOf>() + + override fun onTouchEvent(event: MotionEvent): Boolean { + val x = event.x.toInt() + val y = event.y.toInt() + var handled = false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + delegateView = findDelegateViewUnder(x, y) + } + + MotionEvent.ACTION_CANCEL -> { + delegateView = null + } + } + delegateView?.let { + event.setLocation(it.width / 2f, it.height / 2f) + if (it.measuredHeight != 0 && it.measuredWidth != 0 && it.isVisible && it.hasOnClickListeners()) { + handled = it.dispatchTouchEvent(event) + } + } + return handled + } + + private fun findDelegateViewUnder(x: Int, y: Int): View? { + delegateViewMap.forEach { entry -> if (entry.value.contains(x, y)) return entry.key } + return null + } + } + + val delegateParentViewValue = ArrayList() + + fun addTouchDelegate(view: View) { + post { + val parentView = view.parent as? ViewGroup + parentView ?: return@post + delegateParentViewValue.add(parentView) + if (parentView.touchDelegate == null) parentView.touchDelegate = MultiTouchDelegate(delegateView = this).apply { +// delegateParentViewMap[this@expandRound] = delegateParentViewValue + } + val rect = Rect() + ViewGroupUtils.getDescendantRect(parentView, this, rect) + rect.left += -left + rect.top += -top + rect.right -= -right + rect.bottom -= -bottom + (parentView.touchDelegate as? MultiTouchDelegate)?.delegateViewMap?.put(this, rect) + + val viewRect = Rect() + parentView.getDrawingRect(viewRect) + //不在父布局内,就再扩一层出去 + if ( + !viewRect.contains(rect.left, rect.top) || + !viewRect.contains(rect.left, rect.bottom) || + !viewRect.contains(rect.right, rect.top) || + !viewRect.contains(rect.right, rect.bottom) + ) { + addTouchDelegate(parentView) + } + } + } + + addTouchDelegate(this) +} + +@SuppressLint("RestrictedApi") +fun View.expand(dx: Int, dy: Int) { + if (dx != 0 || dy != 0) { + expandRound(dx, dy, dx, dy) + } +} + +@SuppressLint("RestrictedApi") +fun View.expandDp(dx: Int, dy: Int) { + if (dx != 0 || dy != 0) { + val expandX = dx.dp + val expandY = dy.dp + expandRound(expandX, expandY, expandX, expandY) + } + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt new file mode 100644 index 0000000..3305754 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +open class UITokenConstraintLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + ConstraintLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenConstraintLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenConstraintLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenConstraintLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt new file mode 100644 index 0000000..78cf58c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt @@ -0,0 +1,62 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatEditText +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +open class UITokenEditView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle) : + AppCompatEditText(context, attrs, defStyleAttr), UIView { + + protected val customViewToken = CustomViewToken() + + init { + includeFontPadding = false + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenEditView) + customViewToken.run { + textUITextToken = typedArray.getString(R.styleable.UITokenEditView_textToken) ?: "" + textUIColorToken = typedArray.getString(R.styleable.UITokenEditView_textColorToken) ?: "" + textUIHintColorToken = typedArray.getString(R.styleable.UITokenEditView_textHintColorToken) ?: "" + textUIDisabledColorToken = typedArray.getString(R.styleable.UITokenEditView_textDisabledColorToken) ?: "" + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenEditView_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenEditView_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenEditView_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenEditView_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenEditView_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenEditView_strokeDisabledColorToken) ?: "" + } + + typedArray.recycle() + + changeTextFont(customViewToken) + changeTextColor(customViewToken) + changeBackground(customViewToken) + changeOutline(customViewToken) + } + + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt new file mode 100644 index 0000000..1e1d3b3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +open class UITokenFrameLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenFrameLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenFrameLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenFrameLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt new file mode 100644 index 0000000..70a2253 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt @@ -0,0 +1,62 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + * + */ +open class UITokenImageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatImageView(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenImageView) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenImageView_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenImageView_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenImageView_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenImageView_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenImageView_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenImageView_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenImageView_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt new file mode 100644 index 0000000..4cde0d1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +open class UITokenLinearLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + LinearLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenLinearLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenLinearLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenLinearLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt new file mode 100644 index 0000000..a4bbf89 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt @@ -0,0 +1,70 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.graphics.drawable.ClipDrawable +import android.graphics.drawable.LayerDrawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ProgressBar +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.getGradientDrawable + +/** + * Created by HJW on 2022/8/31 + * + */ +class UITokenProgressBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + ProgressBar(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenProgressBar) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenProgressBar_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenProgressBar_backgroundPressedColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenProgressBar_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_bottomRightRadiusToken) ?: "").ifEmpty { radius } + } + typedArray.recycle() + + val backDrawable = getGradientDrawable( + customViewToken.backgroundUIColorToken, + customViewToken.topLeftRadiusToken, + customViewToken.topRightRadiusToken, + customViewToken.bottomRightRadiusToken, + customViewToken.bottomLeftRadiusToken, + ) + + val gradientDrawable = getGradientDrawable( + customViewToken.backgroundUIPressedColorToken, + customViewToken.topLeftRadiusToken, + customViewToken.topRightRadiusToken, + customViewToken.bottomRightRadiusToken, + customViewToken.bottomLeftRadiusToken, + ) + + val bgClipDrawable = ClipDrawable(backDrawable, Gravity.START, ClipDrawable.HORIZONTAL) + bgClipDrawable.level = 10000 + val progressClip = ClipDrawable(gradientDrawable, Gravity.START, ClipDrawable.HORIZONTAL) + + val layerDrawable = LayerDrawable(arrayOf(bgClipDrawable, progressClip, progressClip)) + layerDrawable.setId(0, android.R.id.background) + layerDrawable.setId(1, android.R.id.secondaryProgress) + layerDrawable.setId(2, android.R.id.progress) + progressDrawable = layerDrawable + progress = progress + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt new file mode 100644 index 0000000..7bfb28e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.RelativeLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +class UITokenRelativeLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + RelativeLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenRelativeLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenRelativeLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenRelativeLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt new file mode 100644 index 0000000..3e2fae7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt @@ -0,0 +1,152 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.graphics.Paint +import android.text.Selection +import android.text.Spannable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * 文字颜色暂时无法做到press enable等效果下渐变色 + * + * ImageView, Layout, Edittext + */ +open class UITokenTextView @JvmOverloads constructor(context: Context, private val attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr), UIView { + + protected val customViewToken = CustomViewToken() + var clickWithSelectableBlock: (() -> Unit)? = null + + init { + includeFontPadding = false + obtainStyledAttributes() + changeStyle() + } + + fun obtainStyledAttributes() { + context.withStyledAttributes(attrs, R.styleable.UITokenTextView) { + customViewToken.run { + textUITextToken = getString(R.styleable.UITokenTextView_textToken).takeIf { it != null } ?: textUITextToken + textUIColorToken = getString(R.styleable.UITokenTextView_textColorToken).takeIf { it != null } ?: textUIColorToken + textUIPressedColorToken = + getString(R.styleable.UITokenTextView_textPressedColorToken).takeIf { it != null } ?: textUIPressedColorToken + textUIHoveredColorToken = + getString(R.styleable.UITokenTextView_textHoveredColorToken).takeIf { it != null } ?: textUIHoveredColorToken + textUIDisabledColorToken = + getString(R.styleable.UITokenTextView_textDisabledColorToken).takeIf { it != null } ?: textUIDisabledColorToken + textUIHintColorToken = getString(R.styleable.UITokenTextView_textHintColorToken).takeIf { it != null } ?: textUIHintColorToken + textUILinkColorToken = getString(R.styleable.UITokenTextView_textLinkColorToken).takeIf { it != null } ?: textUILinkColorToken + backgroundUIColorToken = + getString(R.styleable.UITokenTextView_backgroundColorToken).takeIf { it != null } ?: backgroundUIColorToken + backgroundUIPressedColorToken = + getString(R.styleable.UITokenTextView_backgroundPressedColorToken).takeIf { it != null } ?: backgroundUIPressedColorToken + backgroundUIHoveredColorToken = + getString(R.styleable.UITokenTextView_backgroundHoveredColorToken).takeIf { it != null } ?: backgroundUIHoveredColorToken + backgroundUIDisabledColorToken = + getString(R.styleable.UITokenTextView_backgroundDisabledColorToken).takeIf { it != null } ?: backgroundUIDisabledColorToken + radiusToken = getString(R.styleable.UITokenTextView_radiusToken).takeIf { it != null } ?: radiusToken + topLeftRadiusToken = (getString(R.styleable.UITokenTextView_topLeftRadiusToken) ?: "").ifEmpty { radiusToken } + topRightRadiusToken = (getString(R.styleable.UITokenTextView_topRightRadiusToken) ?: "").ifEmpty { radiusToken } + bottomLeftRadiusToken = (getString(R.styleable.UITokenTextView_bottomLeftRadiusToken) ?: "").ifEmpty { radiusToken } + bottomRightRadiusToken = (getString(R.styleable.UITokenTextView_bottomRightRadiusToken) ?: "").ifEmpty { radiusToken } + strokeUIWidthToken = getString(R.styleable.UITokenTextView_strokeWidthToken).takeIf { it != null } ?: strokeUIWidthToken + strokeUIColorToken = getString(R.styleable.UITokenTextView_strokeColorToken).takeIf { it != null } ?: strokeUIColorToken + strokeUIPressedColorToken = + getString(R.styleable.UITokenTextView_strokePressedColorToken).takeIf { it != null } ?: strokeUIPressedColorToken + strokeUIHoveredColorToken = + getString(R.styleable.UITokenTextView_strokeHoveredColorToken).takeIf { it != null } ?: strokeUIHoveredColorToken + strokeUIDisabledColorToken = + getString(R.styleable.UITokenTextView_strokeDisabledColorToken).takeIf { it != null } ?: strokeUIDisabledColorToken + outlineToken = getString(R.styleable.UITokenTextView_outlineToken).takeIf { it != null } ?: outlineToken + xClickExpend = getDimensionPixelOffset(R.styleable.UITokenTextView_xClickExpend, xClickExpend) + yClickExpend = getDimensionPixelOffset(R.styleable.UITokenTextView_yClickExpend, yClickExpend) + strokeDashWidth = getDimensionPixelOffset(R.styleable.UITokenTextView_strokeDashWidth, 0).toFloat() + strokeDashGap = getDimensionPixelOffset(R.styleable.UITokenTextView_strokeDashGap, 0).toFloat() + onlyIconFont = getBoolean(R.styleable.UITokenTextView_onlyIconFont, onlyIconFont) + underline = getBoolean(R.styleable.UITokenTextView_underline, underline) + fixTextIsSelectable = getBoolean(R.styleable.UITokenTextView_fixTextIsSelectable, fixTextIsSelectable) + } + + } + } + + /** + * textView设置了isSelectable = true时 + * 解决android 8 TextView设置textIsSelectable="true" 报角标越界异常bug + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (customViewToken.fixTextIsSelectable) { + val startSelection = selectionStart + val endSelection = selectionEnd + if (startSelection < 0 || endSelection < 0) { + Selection.setSelection(text as Spannable, text.length) + } else if (startSelection != endSelection) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + val text = text + setText(null) + setText(text) + } + } + } + return super.dispatchTouchEvent(event) + } + + private var lastActionDownTime = -1L + + /** + * TextView设置了长按复制、OnClickListener冲突的解决 + * @param event MotionEvent + * @return Boolean + */ + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (isTextSelectable && clickWithSelectableBlock != null) { + when (event?.action) { + MotionEvent.ACTION_UP -> { + val actionUpTime = System.currentTimeMillis() + if (actionUpTime - lastActionDownTime <= ViewConfiguration.getLongPressTimeout()) { + /** + * 这里失焦一下 + */ + clickWithSelectableBlock?.invoke() + return true + } + } + + MotionEvent.ACTION_DOWN -> { + lastActionDownTime = System.currentTimeMillis() + } + } + } + return super.onTouchEvent(event) + } + + private fun changeStyle() { + changeTextFont(customViewToken) + changeTextColor(customViewToken) + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + if (customViewToken.underline) { + paint.flags = Paint.UNDERLINE_TEXT_FLAG + paint.isAntiAlias = true + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt new file mode 100644 index 0000000..b07dac8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt @@ -0,0 +1,12 @@ +package com.remax.visualnovel.widget.uitoken.view + +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken + +/** + * Created by HJW on 2022/9/20 + */ +interface UIView { + + fun getUITokenView(): CustomViewToken + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/anim_keep.xml b/VisualNovel/app/src/main/res/anim/anim_keep.xml new file mode 100644 index 0000000..75d2752 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/anim_keep.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml b/VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml new file mode 100644 index 0000000..0a98dbd --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml b/VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml new file mode 100644 index 0000000..7ea4c68 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml b/VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml new file mode 100644 index 0000000..28a93a3 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_translate_show.xml b/VisualNovel/app/src/main/res/anim/dialog_translate_show.xml new file mode 100644 index 0000000..a3e2717 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_translate_show.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/no_anim.xml b/VisualNovel/app/src/main/res/anim/no_anim.xml new file mode 100644 index 0000000..19107ad --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/no_anim.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_anim_in.xml b/VisualNovel/app/src/main/res/anim/picker_anim_in.xml new file mode 100644 index 0000000..4624111 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_anim_in.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_anim_up.xml b/VisualNovel/app/src/main/res/anim/picker_anim_up.xml new file mode 100644 index 0000000..ffea42c --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_anim_up.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_fade_in.xml b/VisualNovel/app/src/main/res/anim/picker_fade_in.xml new file mode 100644 index 0000000..c73f2e4 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_fade_in.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_fade_out.xml b/VisualNovel/app/src/main/res/anim/picker_fade_out.xml new file mode 100644 index 0000000..e23d4e7 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_fade_out.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml b/VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml new file mode 100644 index 0000000..385500e --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/anim/picker_show2bottom.xml b/VisualNovel/app/src/main/res/anim/picker_show2bottom.xml new file mode 100644 index 0000000..e445754 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_show2bottom.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/VisualNovel/app/src/main/res/anim/picker_top_in.xml b/VisualNovel/app/src/main/res/anim/picker_top_in.xml new file mode 100644 index 0000000..b081293 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_top_in.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_top_out.xml b/VisualNovel/app/src/main/res/anim/picker_top_out.xml new file mode 100644 index 0000000..c86790b --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_top_out.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml b/VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml new file mode 100644 index 0000000..a4ba867 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml b/VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml b/VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/progress_recording.xml b/VisualNovel/app/src/main/res/drawable/progress_recording.xml new file mode 100644 index 0000000..faa884a --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/progress_recording.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml new file mode 100644 index 0000000..9abd3b7 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml new file mode 100644 index 0000000..970743a --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml b/VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml new file mode 100644 index 0000000..3cb27ef --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml b/VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml new file mode 100644 index 0000000..1c8cbcc --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/button_with_icon.xml b/VisualNovel/app/src/main/res/layout/button_with_icon.xml new file mode 100644 index 0000000..d9dab79 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/button_with_icon.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_double_btn.xml b/VisualNovel/app/src/main/res/layout/dialog_double_btn.xml new file mode 100644 index 0000000..502c57a --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_double_btn.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_loading.xml b/VisualNovel/app/src/main/res/layout/dialog_loading.xml new file mode 100644 index 0000000..9586a55 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_loading.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml new file mode 100644 index 0000000..3438c1f --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml new file mode 100644 index 0000000..c6c9109 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml b/VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml new file mode 100644 index 0000000..9080fda --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml b/VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml new file mode 100644 index 0000000..20611a1 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_actor.xml b/VisualNovel/app/src/main/res/layout/fragment_main_actor.xml new file mode 100644 index 0000000..452f402 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_actor.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_book.xml b/VisualNovel/app/src/main/res/layout/fragment_main_book.xml new file mode 100644 index 0000000..c952614 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_book.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_history.xml b/VisualNovel/app/src/main/res/layout/fragment_main_history.xml new file mode 100644 index 0000000..50d307f --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_history.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_manga.xml b/VisualNovel/app/src/main/res/layout/fragment_main_manga.xml new file mode 100644 index 0000000..4e75101 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_manga.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/VisualNovel/app/src/main/res/layout/item_album.xml b/VisualNovel/app/src/main/res/layout/item_album.xml new file mode 100644 index 0000000..176c2a4 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_album.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml b/VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml new file mode 100644 index 0000000..a0ced63 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml b/VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml new file mode 100644 index 0000000..393a1db --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml b/VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml new file mode 100644 index 0000000..16868a6 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml b/VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml new file mode 100644 index 0000000..280c655 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/layout_empty.xml b/VisualNovel/app/src/main/res/layout/layout_empty.xml new file mode 100644 index 0000000..c78da76 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/layout_empty.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/layout_epal_crop.xml b/VisualNovel/app/src/main/res/layout/layout_epal_crop.xml new file mode 100644 index 0000000..09a44f3 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/layout_epal_crop.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/layout_toolbar.xml b/VisualNovel/app/src/main/res/layout/layout_toolbar.xml new file mode 100644 index 0000000..90ea950 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/layout_toolbar.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/load_more_loading_view.xml b/VisualNovel/app/src/main/res/layout/load_more_loading_view.xml new file mode 100644 index 0000000..3f1c017 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/load_more_loading_view.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml b/VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml new file mode 100644 index 0000000..f489527 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml @@ -0,0 +1,33 @@ + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_crop.xml b/VisualNovel/app/src/main/res/layout/picker_activity_crop.xml new file mode 100644 index 0000000..60383d1 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_crop.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml b/VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml new file mode 100644 index 0000000..dc2409e --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml b/VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml new file mode 100644 index 0000000..26e6fe9 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml b/VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml new file mode 100644 index 0000000..58c6d34 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml b/VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml new file mode 100644 index 0000000..9b39335 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_preview.xml b/VisualNovel/app/src/main/res/layout/picker_activity_preview.xml new file mode 100644 index 0000000..e37e2d6 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_preview.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_folder_item.xml b/VisualNovel/app/src/main/res/layout/picker_folder_item.xml new file mode 100644 index 0000000..55b79d7 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_folder_item.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml b/VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml new file mode 100644 index 0000000..74c74d2 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_item_camera.xml b/VisualNovel/app/src/main/res/layout/picker_item_camera.xml new file mode 100644 index 0000000..ed75b92 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_item_camera.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_item_root.xml b/VisualNovel/app/src/main/res/layout/picker_item_root.xml new file mode 100644 index 0000000..6a4e051 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_item_root.xml @@ -0,0 +1,5 @@ + + diff --git a/VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml b/VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml new file mode 100644 index 0000000..78f35c9 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml b/VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml new file mode 100644 index 0000000..69bd70c --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml new file mode 100644 index 0000000..dcb8177 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml new file mode 100644 index 0000000..717cca7 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml new file mode 100644 index 0000000..937bbf3 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_tips.xml new file mode 100644 index 0000000..6af1d8c --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_tips.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/view_load_more_common.xml b/VisualNovel/app/src/main/res/layout/view_load_more_common.xml new file mode 100644 index 0000000..db4ebca --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/view_load_more_common.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/widget_item_like.xml b/VisualNovel/app/src/main/res/layout/widget_item_like.xml new file mode 100644 index 0000000..0b6bebf --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_item_like.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/widget_lock_tag.xml b/VisualNovel/app/src/main/res/layout/widget_lock_tag.xml new file mode 100644 index 0000000..ece0aa9 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_lock_tag.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/widget_price_view.xml b/VisualNovel/app/src/main/res/layout/widget_price_view.xml new file mode 100644 index 0000000..c0f2e32 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_price_view.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/widget_user_avatar.xml b/VisualNovel/app/src/main/res/layout/widget_user_avatar.xml new file mode 100644 index 0000000..15aa8e2 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_user_avatar.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/icon_new_empty.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/icon_new_empty.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/icon_new_empty.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_off.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_off.webp new file mode 100644 index 0000000..9fa4665 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_off.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_on.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_on.webp new file mode 100644 index 0000000..d723a28 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_on.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_off.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_off.webp new file mode 100644 index 0000000..79c76f3 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_off.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_on.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_on.webp new file mode 100644 index 0000000..df3c137 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_on.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_off.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_off.webp new file mode 100644 index 0000000..3d3addd Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_off.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_on.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_on.webp new file mode 100644 index 0000000..df54c70 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_on.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_off.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_off.webp new file mode 100644 index 0000000..4c50497 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_off.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_on.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_on.webp new file mode 100644 index 0000000..91a9f7f Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_on.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_expand.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_expand.webp new file mode 100644 index 0000000..a9d43f7 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_expand.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_shrink.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_shrink.webp new file mode 100644 index 0000000..79b2a32 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_shrink.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/checkbox_normal.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/checkbox_normal.webp new file mode 100644 index 0000000..d6062b7 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/checkbox_normal.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_female.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_female.webp new file mode 100644 index 0000000..1cadf71 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_female.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_male.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_male.webp new file mode 100644 index 0000000..149ad18 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_male.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_nonconforming.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_nonconforming.png new file mode 100644 index 0000000..e6b18a8 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_nonconforming.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_status_error.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_status_error.webp new file mode 100644 index 0000000..13545c4 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_status_error.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_tips.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_tips.webp new file mode 100644 index 0000000..db75a2c Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_tips.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_checked.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_checked.webp new file mode 100644 index 0000000..9d59f45 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_checked.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_diamond.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_diamond.webp new file mode 100644 index 0000000..40f56f3 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_diamond.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked.webp new file mode 100644 index 0000000..7a9d671 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked_disabled.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked_disabled.webp new file mode 100644 index 0000000..ef8262d Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked_disabled.webp differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fill.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fill.png new file mode 100644 index 0000000..ed92f63 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fill.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fit.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fit.png new file mode 100644 index 0000000..ac2f8ef Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fit.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_full.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_full.png new file mode 100644 index 0000000..a4881ac Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_full.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_haswhite.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_haswhite.png new file mode 100644 index 0000000..9ebce3c Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_haswhite.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_video.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_video.png new file mode 100644 index 0000000..6ec8e45 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_video.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video.png new file mode 100644 index 0000000..14c8398 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video_mask.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video_mask.png new file mode 100644 index 0000000..77fe43c Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video_mask.png differ diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/radio_normal.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/radio_normal.webp new file mode 100644 index 0000000..f913d99 Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/radio_normal.webp differ diff --git a/VisualNovel/app/src/main/res/raw/like.json b/VisualNovel/app/src/main/res/raw/like.json new file mode 100644 index 0000000..af8ee10 --- /dev/null +++ b/VisualNovel/app/src/main/res/raw/like.json @@ -0,0 +1 @@ +{"v":"5.7.1","fr":24,"ip":0,"op":24,"w":144,"h":144,"nm":"48-25","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"形状 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[72,72,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":6,"s":[0,0,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":12,"s":[120,120,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[90,90,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":19,"s":[110,110,100]},{"t":23,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.65,0],[-1.44,-1.33],[0,0],[0,-2.1],[1.33,-1.44],[0,0],[0,0],[1.16,0],[0.78,0.7],[0,0],[0,0],[0,2.09],[-1.49,1.48],[-2.1,0],[-1.27,-0.91]],"o":[[1.99,0],[0,0],[1.49,1.48],[0,1.98],[0,0],[0,0],[-0.81,0.81],[-1.07,0],[0,0],[0,0],[-1.49,-1.48],[0,-2.1],[1.48,-1.49],[1.66,0],[1.27,-0.91]],"v":[[4.39,-10.5],[9.54,-8.5],[9.77,-8.27],[12,-2.9],[10,2.23],[9.77,2.46],[2.95,9.28],[0,10.5],[-2.77,9.45],[-2.95,9.28],[-9.77,2.46],[-12,-2.9],[-9.77,-8.27],[-4.39,-10.5],[0,-9.13]],"c":true},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"op","nm":"位移路径 1","a":{"a":0,"k":-1,"ix":1},"lj":1,"ml":{"a":0,"k":4,"ix":3},"ix":2,"mn":"ADBE Vector Filter - Offset","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[300,300],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"形状","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[72,72,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":6,"s":[0,0,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":12,"s":[120,120,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[90,90,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":19,"s":[110,110,100]},{"t":23,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.65,0],[-1.44,-1.33],[0,0],[0,-2.1],[1.33,-1.44],[0,0],[0,0],[1.16,0],[0.78,0.7],[0,0],[0,0],[0,2.09],[-1.49,1.48],[-2.1,0],[-1.27,-0.91]],"o":[[1.99,0],[0,0],[1.49,1.48],[0,1.98],[0,0],[0,0],[-0.81,0.81],[-1.07,0],[0,0],[0,0],[-1.49,-1.48],[0,-2.1],[1.48,-1.49],[1.66,0],[1.27,-0.91]],"v":[[4.39,-10.5],[9.54,-8.5],[9.77,-8.27],[12,-2.9],[10,2.23],[9.77,2.46],[2.95,9.28],[0,10.5],[-2.77,9.45],[-2.95,9.28],[-9.77,2.46],[-12,-2.9],[-9.77,-8.27],[-4.39,-10.5],[0,-9.13]],"c":true},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.882352948189,0.164705887437,0.164705887437,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[300,300],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/raw/single_ring.json b/VisualNovel/app/src/main/res/raw/single_ring.json new file mode 100644 index 0000000..283afc7 --- /dev/null +++ b/VisualNovel/app/src/main/res/raw/single_ring.json @@ -0,0 +1 @@ +{"v":"5.6.9","fr":24,"ip":0,"op":48,"w":64,"h":64,"nm":"single ring","ddd":0,"assets":[{"id":"0","w":62,"h":62,"u":"","p":"data:image/webp;base64,UklGRuQJAABXRUJQVlA4WAoAAAAQAAAAPQAAPQAAQUxQSC4EAAANoKVt21pXed/vT7rq7W5xd3d3d2cUDmOAfcgUOOLM3d3d2e5e9y5N8n8vTptMICImACtM0AA5hJJTMAsJPEYXyi+rJYxth0BAZWNvl7czJ4TgYNl6+rN2GwQpCGW34bDccQMhN8ngLFN6SH05GgABJkAiStw3Mt2RiU4RUZKIEg/3T2YGABQ7haPkI90TMUAU2G6j9IP9E24CZfkiUfraIWNFEAUu11H+cOxYx5wCZwpU8Mj5hhGQTaGKQ7UZQQQnUUGmh07kJMVxVJB+cLYYAIX5ZgXotcE5J2T1GVRyMMymgjCOKioZnYaAdMYrgb6e6URAPgWqCoN5Mwhdc5FCBcPoRCqiWEQ1ksG5IFhjCdVc08woJM2MXomhRcJQLKGi/U3CGavTIMCiWZU0AqDaUDX6mhQRM1YkjTJQBVHyhH+TiQKEkptvOyKaGDwBALrKBQuggE5RE+GBWakYj5xSlGFvpwcArLtOlQhYs1iAsvrSsEAkffMos/lp8+0ghW3NAdGRDMxRJaIuaNWNsq1Th4nw9KBxocx2SWwEkL/OD9aiwXvTeao0oXPafLNLYn1zsXBopOWjIztKRN21MNstdX/an42d1iEUDptpoayMPRdkC6ni8IvAxJo+B7ITbad5SZLsqubEaMFkah9QXz4ug3ly0q4WVQrLR+5fmkyQj7w6ALbGTnKDtU/jVqKcfmPcM+KyzutIgN3pSW2K8eqNs1QJ6MfddUBQHPoowGVL4ycGGYqDz//YUYonJnd0u9LGuwgFnJuHj81EtM869HNq1ax4aM/WIWfs/2ZvkgNg47eLehxUfqN/hlUmiwd7t3S7effM0wgC4Ny5cJEEU3FL9vnqWNQDvRsTQAzPIGT4u/D1KYcXlHm8PfvMCa0QWSQPcl1iROz55X1E/GvnzVtGcgMU7zj0rWmYtAIMuY57fPOvwaTYt+8J/FfOfnlXV0FC+QUXrVvfQJD/D7M8Dl138/ofuyjFnvpaUP8B3Ln59pCbgc1jb0h/2diBUYIAECRy77n+suKHXf0RjLViLczxHwX+NH5HLQdgGS84t3/Xr/sFgAAcEo655rTWz5/XUieLvtZahgL/Xfxx513DOSjzxvAZxx47sH33WH0OwODICSec2dy98ZPOsEtQ3+4nQcf/FTZ+ce8xngUF5o2+Q0ePO+rQviFjaNXHxndOb88GGQF1cd1TAPS/AMy8cMYVA20AgUUWa7VakoLI86zVSnoYicJ651/8GJSwou33F688sasNgiZHlOgKpmAuwTHc+vlZAMJKh3WfHXnmGaEZjYAIioBTgDzptU+/3IkQsfLR2l/v9AsuGG4UAggHBQBiOjT9zWcNwHKsppNzO9bNrrn0tB6ogCjCYGF541tjQ0ASI1ZXotXHd2/ee/jRhx/RdXCKzuLi3vHfZ/oABGX431ZQOCCQBQAA0BkAnQEqPgA+AAAAACWkAFeAfwD8Hf1A8Zf4T+CX6h/0zuQe23pV+uX9d5Z7FD5r+Jv6zf3z2K/wA/ADlDP4p/N/xA/WT+yZx38c/mP4r/1D/O/6XZJfxD+a/iRtAH8E/kf9e/Ef+m/Dn+mfgd60/mX/Dfil9AP8K/iH8x/pf6m/2X/Z/Rn64P1m9hj9DTpnadIIbaaBJbZAtX/6tzxT38S9j93x+lIReG5GUG5ZIar86G+SeCNaBfiLSB/X8tEzG7AVaoiVypp2Yy7n8/zM7O6bAPLLMAAA/vii9f3N1yo2l349Tty+Gz6P9TKdVjtWeEqZ/v4bpDRNcMgUC/6DLHT6R8pki3ujTSmYZXCNgjf+oY0b6lsli2Ym6PnYdugp2kGofW/1emNDiDGhoU5hQTFiM4SFJ9uNcmrqi9naFTTXetFdKdXLGopi20Sskdd29l1JaoPyE+8FQ5byiymSgGkkCPPxYCY6o8yB//laSAYzgubkhV2e1UXsOb9XHbQDGxmYGX71pSqb+N52UxdzEP6C1c9Rg47SOl+vgm5z9X2dYVI0MzVjDB/98IpFtRalyMI+cQj4v2LDqV+7yTsMoqFFz7DhpveKJnasvJixTn74Jsg5GSaT+B7bVjXLIUXf30htTbufqckma/X+e4X73dGwK77JHkSUAS/HMQ2XWBkrtTEWVgzCT+2GZmEIEh4Nyb9AA0Dz5IE0o9ZfqY3jBiIPtO/9XDCz9Bfb01MQ0teMjCyOxKJXx1fDF13XqAC+h43PDYcT8wjFvSypYfPG1LFZp8cQObm1zdv+OTeosd/+QwQaOFBf0SgjHIo0N9PYUq5bK0MwGl8kyEuU0afoenEPoYl7h3CoE0kC8JZtzaOCJh9HycY09QMkOfhHfS6BQ9ZhmBSEA7WMBiiKg4utup/1QEfZXxgl+yC75KClFtZ680nr0BqpmKg1xLY3L2XmJX4Rsz0S5ZCm/eknZoDW1952pm/nUppi1SDmri3BR6DFuJYMQe6a36rWotyYJ4ogMfMDqM//+ZnGzer//OSA4C+X2pV31PSdO9sEcuuCyDPRDme8BfxrFT3SHF9T97uqJ1qSo10HW//5m/ycsAO5AN2rh2kl712T+J6PuiQwzxBAemQQi/HrCEgD2UjO+sD45J9j6BopHbNWE/7xTz7rdriXxarbiw6w94az+B6a4vR2wSW/99Gg0fpZ71JFxUkAjFt2juQK7u4eOKHasaCqiEWBopBTHZcOUlIFNeHs7n+oEgNPf+TpDVyQOXrMCmDMxjymEe7Lisn/+WiCVKRIfnwPMxGuvgqcKFAXzj1mrdHlY/zDyv3nUJkZZ3qZnazrtaPbpDyRUBsCbGz6SJ7GtnA6tCVTR8MMuUk01c36oWZnFLWkrgD63L/MVav1fXnxJ3+C+Lm/HfnlWdbZz1n5OH/SurvtKPjc6YzGx22RHi70iqmO1cmcsNCOQMOpND9uwkctZd7yN8KhOjtl6FjOXNtIh026dhSGb3fJUsa/vlL6TaC+m1ioJUqNEs6fXrsguMB4Qu+OrqEACWgDT9fwCUFTzk6Wv5YQC//5fVhzmPjXJm2Maw+I6FNXBu0PwalDKLYfRPt3W3qZmMTRTJgtssJtJUWAracEHpUbtwqte53a+IyE1f/+pQIf//0ZsTG+6+omUkGn9ywVc+AqEdVb7wv4owSq86TuMorf3kCOMGr5GBcM7RJ1qlkYlsnLLGStpDryakBKn/OvxwLWT+TACFtSk7iHU5+AENK9oH1v4+iy5rRol0phRJQa756OM01lES3vuRtyuTjg//5iqtVsLUlOvSalUkXtGL+jCgfpCX6kQq6GW01cYo8BnnL5k3Mlr0cp8dIuevpynb4axWxs/juqS/IbsNyd4d+my8AAAAA=","e":1}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"Ellipse 1.png","cl":"png","refId":"0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":48,"s":[360]}],"ix":10},"p":{"a":0,"k":[32,32,0],"ix":2},"a":{"a":0,"k":[31,31,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":48,"st":0,"bm":0}],"markers":[],"tiny":1} \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values-night/themes.xml b/VisualNovel/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..3579cf4 --- /dev/null +++ b/VisualNovel/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/attrs.xml b/VisualNovel/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..1b12daa --- /dev/null +++ b/VisualNovel/app/src/main/res/values/attrs.xml @@ -0,0 +1,1452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/colors.xml b/VisualNovel/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fa7dd30 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/colors.xml @@ -0,0 +1,188 @@ + + + #13131A + #99120E1B + #CC120E1B + + #F6F6FF + + #33BCBEFF + #33BCBEFF + #FFFFFF + #ADADBF + + + #FF6252F9 + #B08EFF + #8957FF + + #FFFFFF + #1AFFFFFF + #26FFFFFF + #33FFFFFF + #40FFFFFF + #4DFFFFFF + #80FFFFFF + #B3FFFFFF + #00000000 + #000000 + #E6000000 + #B3000000 + #2B000000 + #A6000000 + #99000000 + #80000000 + #4D000000 + #33000000 + #26000000 + #1A000000 + #26FFFFFF + + + + + #FFECDE + #FFD7B8 + #FFBF8F + #FFA264 + #FD8239 + #F25E0F + #99F25E0F + #D04500 + #A83400 + #7B2300 + #4D1400 + #FFF8DE + #FFEFB3 + #FFE386 + #FCD258 + #F3BC2A + #E6A100 + #C78800 + #A26B00 + #784D00 + #4D2F00 + #F8FFDE + #EDFCB8 + #E0F68F + #CFED67 + #BAE041 + #A0CD1E + #82B500 + #689600 + #4B7200 + #304D00 + #DEFFE7 + #B9FCCD + #94F7B1 + #6FEE96 + #4AE27B + #28D061 + #0BB84A + #00983C + #007331 + #004D22 + #DEFFF8 + #B6FBED + #8DF3E2 + #65E9D5 + #3FDAC4 + #993FDAC4 + #1DC7B0 + #00AD96 + #009182 + #006F67 + #004D49 + #DEECFF + #B5D2FD + #8CB5F9 + #6296F2 + #3A76E6 + #1E58D2 + #7B1E58D2 + #063BB8 + #002A98 + #001E73 + #00134D + #DEE0FF + #BCBEFF + #33BCBEFF + #9797FF + #7370FF + #4E48FF + #994E48FF + #3126E6 + #180AC7 + #0F00A2 + #0D0078 + #09004D + #E4DEFF + #C7B7FD + #AA90F9 + #8D68F2 + #7B47FF + #997B47FF + #7B7B47FF + #5923D2 + #4309B8 + #340098 + #290073 + #1C004D + #FBDEFF + #14FBDEFF + #33FBDEFF + #F2B7FD + #E690F9 + #D668F2 + #C241E6 + #A823D2 + #8A09B8 + #6E0098 + #520073 + #36004D + #FBDEFF + #FDB6D3 + #F98DBC + #F264A4 + #E63C8B + #D21F77 + #99D21F77 + #B80761 + #980050 + #73003E + #4D002A + #FFDEDE + #FFBCBC + #FF9696 + #F97372 + #EF4E4D + #E12A2A + #C2110E + #A00700 + #770800 + #4D0600 + #E8E4EB + #D4D0D8 + #AAA3B1 + #958E9E + #847D8B + #706A78 + #5C5565 + #484151 + #352E3E + #282233 + #211A2B + + + @color/white + @color/black + #7B270A + + + + + + #ffbac5d2 + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/dimens.xml b/VisualNovel/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..78e3401 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/dimens.xml @@ -0,0 +1,146 @@ + + + 16dp + 12dp + 12dp + 20dp + 8dp + 0.5dp + 4dp + 8dp + + 10sp + 12sp + 14sp + 16sp + 18sp + 20sp + + + 7dp + 3dp + + + 44dp + 16dp + 16dp + + + 0.00 + 0.02 + 0.04 + 0.06 + 0.08 + 0.12 + 0.15 + 0.20 + 0.25 + 0.30 + 0.45 + 0.5 + 0.65 + 0.85 + 1.00 + + 0% + 2% + 4% + 6% + 8% + 12% + 15% + 20% + 25% + 30% + 45% + 65% + 85% + 100% + + + LTR + TTB + LTTRB + + + Poppins + D-Din + + Poppins + Bangers + + 64sp + 48sp + 36sp + 32sp + 24sp + 23sp + 20sp + 18sp + 16sp + 14sp + 12sp + + 400 + 500 + 600 + 700 + + 8dp + 4dp + 6dp + 2dp + 2dp + 2dp + 3dp + 4dp + 3dp + 4dp + 0dp + + + + 1 + 1 + + + 4 + 3 + + + 3 + 2 + + + 2 + 1 + + + 16 + 9 + + + + 4dp + 8dp + 12dp + 16dp + 20dp + 24dp + 40dp + 42dp + 80dp + 999dp + + + 0.5dp + 1dp + 2dp + 4dp + + 24dp + 220dp + 240dp + 300dp + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/iconfontkey.xml b/VisualNovel/app/src/main/res/values/iconfontkey.xml new file mode 100644 index 0000000..1f04fbd --- /dev/null +++ b/VisualNovel/app/src/main/res/values/iconfontkey.xml @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/ids.xml b/VisualNovel/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..9a183b6 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/ids.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/strings.xml b/VisualNovel/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..513ab57 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/strings.xml @@ -0,0 +1,466 @@ + + + VisualNovel + + @string/google_web_client_id + 793744761373-pkous5kso3843kmdpbo5id18obr17dii.apps.googleusercontent.com + fcm_default_channel + + %1$d contacts have new messages + New message from %1$s + %1$s sent a photo message + %1$s sent a voice message + %1$s sent a video message + %1$s sent a file message + %1$s shared a location + %1$s: Notification message + %1$s: Audio/video call + %1$s: Reminder message + %1$s: Custom message + %1$s: Unable to display this content + You received a new message + New message + + Your network is unstable, please try again + Loading… + Copy successful + Share + Search + support@VisualNovel + No permission yet + 语音时间太短 + + Image does not exist + The image cannot exceed 10MB + charge succeeded + + + + Video And Picture + Video Select + Picture Select + + Full + Gap + + All Medias + All Video + + You have denied the permission to take photos. If you refuse to set this function, it will not work! Do you want to set it + up? + + You have denied the storage permission. If you refuse to set, the function will be unavailable! Do you want to set it up? + + I refuse + OK + + Please select at least one file loading type! + Clipping is abnormal. The picture has been reset for you. Please try again! + This file has been selected or cannot be selected! + No media files found + Only pictures can be selected! + Only video can be selected! + Video duration shall not exceed + Video duration shall not be less than + Preview video not supported! + Only select one video! + Do not operate too fast! + + Today + This Week + This months + yyyy-MM + : + preview list isEmpty! + + All Picture + Choose + + + s + m + h + d + + Yesterday + MMM d, yyyy + MMM d + + + + + + + + + + + MMM d, HH:mm + MMM d, HH:mm:ss + MMM d, yyyy, HH:mm + HH:mm + MMM d, yyyy, HH:mm:ss + MMM yyyy + yyyy + Sec + Min + Upload up to %d images + Your image failed to upload, please try again + + + Confirm + Continue + Discord + Google + Cancel + Sign In With + Login Cancel + Personal Information + Please check the Google account login status + More + Optional + Download + sort by + no_more_data + Chat.novel.AI Date + Login or Signup + From \"Hello\" to \"I Do\", every conversation is full of heart + Nickname + Age + 请输入你的昵称 + 对角色的称呼 + Gender + Birthday + Please Select + Please Select Birthday + Select + Can\'t change after submit + Register + User Agreement + Privacy Policy + By clicking to register, you have read and agree to + and + The age cannot be less than 18 years old + 昵称只能包含2-20字符 + 该昵称已存在 + 只能包含%d-%d个字符 + 最多输入%d个字符 + ID + OK + Message + Friends + Membership + Diamonds + Creator + No Character Yet + Characters + Unlock more + Setting + Account + About + About Us + Log out + Submit + Male + Female + Nonconforming + Other + Tips + Delete + 角色删除成功 + Delete All + Read All + Delete Account + 账号删除后数据不可恢复。请确认是否删除该账号 + 账号删除成功 + Got it + Classification + Role + Original + 同人 + personality + tag + Next + Exit + AI Generate + 内容未保存,是否继续退出? + Character + 请描述角色的背景、性格、身份 + 请描述角色的聊天方式,对话语气 + 请描述角色的开场白 + 请选择角色语音音色 + Create + Complete + Dialogue style + Opening remarks + Voice + Character Voice + Select a voice from below + Voice Recommend + 请确认该虚拟角色是您的原创或同人创作,不侵犯他人的图像,IP或其他权利。 + Appearance + Regenerate + Click to generate images + Introduction + 为用户介绍该虚拟角色 + Avatar + Crop + Privacy + Public + Private + Generating + Generate Image + Image + Style + Description + 请描述形象的肤色、服饰、发型、五官、动作、背景等 + upload error + Warning + Profile + Not Now + Go + AI创建的内容会覆盖你已经填写的内容,请确认是否继续? + 前往个人主页,为角色创建相册,吸引对话者,增加收入。 + Edit + Album + Chat + Liked + Chats + People + Gifts + Unlock + No Album Yet + 图片删除后不可恢复。已经付费解锁过该图片的用户依然可以在角色的相册中看到该图片。 + 设置为默认图片后,图片的解锁方式只能为“免费” + 不可删除封面默认图片,该图片会作为在个人主页头图,卡片主图,聊天背景 + Unlock Method + 对话者通过付费方式查看虚拟角色的图片,可以增加创作者的收入分成 + VisualNovel平台会从每张图片的销售收入中,分成20%作为平台服务费 + 设置若干免费图片,可以吸引对话者与你的虚拟角色互动 + Free + Paid + Set as default image + Default Image + Set + Apply to personal homepage header, card main image, chat background + Reference + AI will create based on the basic image of the character + Non-members have 10 free creations + VIP Remaining Times + Remaining Times + Free Remaining Times + Buy VIP + 10/Month + Buy Times + Buy Credits + Price + Time + Quantity + Total Price + Confirm the work + Unlock Price + 解锁成功 + The price range is %d – %d + Press again to close app + Intro + Default + Save + Save Successful + Delete Role + 删除角色不可恢复。为保障用户体验,角色删除后,已经与该角色发生过聊天的或者付费行为的用户,还可以正常与该角色互动。 + 角色已被删除 + 该角色已被删除,无法访问和互动。 + Give up + 放弃创作 + 选择退出或重新生图片,已经创作的图片将消失,同时消耗1次创作次数。 + 选择退出或重新生图片,已经创作的图片将消失,不会退还原已经消耗的novel coin + Inappropriate image + Unlock Instructions + Low + High + Slow + Fast + Subscribe + Tap to Listen + Picture + Call + Chat Background + Notice + Title + 心动值总和排名:top %s %% + 心动值15.0℃以上可出现在关系列表 + 与你的心动值达到15.0℃以上的角色作为排名对象,按照这些角色心动值总和进行排名 + no_message_yet + no_friend_yet + no_result_yet + no_notice_yet + Delete Message + 删除消息后,将清空该聊天的消息记录 + 删除全部消息后,将清空所有的消息记录 + Check + Prompt + Heart member unlock + Member unlock + Hold to Talk + Release to send + My Chat Personal + Chat Setting + Auto play voice + Who i am + Chat Model + Chat Bubble + Unfilled + + Meet + Friend + Flirting + Couple + Married + Retrieve + Retrieve heart value + Hide Relations + Purchase + 内容由AI生成 + 相识%d天 | 心动分超过%s%%的对话者 + Deducted cardiac value: -%s℃ + 通过聊天或送礼增加心动值,24小时不联系心动值会自动扣减 + 虚拟角色会根据对话的情绪感受,酌情判断增加或者减少心动值 + 心动值会提升心动等级,通过升级解锁称号,功能,以及不同的角色对话阶段 + 虚拟角色对你的称呼 + *gender cannot be changed + 描述你所扮演角色的人物背景、性格特征 + 选择退出或重新生图片,已经创作的图片将消失,不会退还已消耗的novel Coin + No background yet + Stay tuned for more models + Role-playing model + Have a role-playing conversation with AI + 文本消息价格是指与角色进行文本消息对话的价格,含发送语音,含发送图片,发送礼物 + 语音通话消息价格是指与角色进行语音电话对话的价格,按条计算 + Gift + Copy + Like + Dislike + likes + Just now + Call Canceled + Call duration + Text Message + Send or play voice + min Voice call + Unlocked \"%s\" title + Losed the title of \"%s\" + Heartbeat %s unlock + Use + Hi + %d to Create + Create image + Unlocked + Waiting to be connected + Interrupt + Listening + Thinking + set background + Only apply to the background of your chat with the character + Generate Filed + Leaderboard + Leaderboards + 热聊榜以AI聊天会话数高低排名 + 心动榜以AI角色所有对话者产生的心动值之和的高低排名 + 礼物榜以AI角色所收到礼物打赏价值之和排名 + Heartbeat + 钻石可用于支付聊天费用,以及解锁其他道具。 + 断签后,会从第一天开始重新签到。 + Day + Check in + Checked + You have checked in for %d consecutive days + 今日已签到 + Filter + Type + Encounter + Not Start + Secret Admirer + Someone is secretly in love with you + Not Yet + You and %s are moved by each other + Thank you for your %d %s + Matched + Giving gifts greatly increases the probability of matching + Wallet + Recharge + novel Coin insufficient + The novel coin is insufficient and cannot continue, please recharge + Income + Balance + No Transaction Yet + Transaction Detail + 获得的收益30日后可提现 + I have read and agree to the + VisualNovel Top-Up Agreement + Creation Income + Pending + Please check connection of Google Account + VisualNovel VIP + Only %s/month, enjoy more benefits + Expiration Date + Terms of Service + You subscribed to Plus on App Store, please go to there to upgrade + You subscribed to Plus on PayPal, please go to there to upgrade + Off + Month + 创建虚拟角色次数已经用完辣 + 你的心动会员已自动续期成功,续期时间至 + 余额不足 + Dialog Model + Swipe to see more + No intention yet + 退出登录 + 请确认是否退出登录 + Dialog + Create high-quality AI characters and win novelCoin. + More Revenue + Stay tuned + Gift Revenue + The interlocutor can pay to send virtual gifts to your AI character. + Grow your love story with VisualNovel AI—From ‘Hi’ to ‘I Do\', sparked by every chat + At VisualNovel AI, every chat writes a new verse in your love epic—\nFrom that tentative \"Hi\" to the trembling \"I do\", find a home for the flirts you never sent, the responses you longed for, and the risky emotional gambles you feared to take. + Contact Us: support@VisualNovel.ai + All + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/styles.xml b/VisualNovel/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..00be17c --- /dev/null +++ b/VisualNovel/app/src/main/res/values/styles.xml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/values/themes.xml b/VisualNovel/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..079ffcd --- /dev/null +++ b/VisualNovel/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +