update: latest source changes
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-kapt'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.image"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "3.0.11"
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
|
||||
buildConfigField("boolean", "ALLOW_GLIDE_LOGS", project.properties.get("EXPO_ALLOW_GLIDE_LOGS", "false"))
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
if (expoModule.safeExtGet("excludeAppGlideModule", false)) {
|
||||
exclude("**/ExpoImageAppGlideModule.kt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
def GLIDE_VERSION = "4.16.0"
|
||||
|
||||
implementation 'com.facebook.react:react-android'
|
||||
|
||||
api "com.github.bumptech.glide:glide:${GLIDE_VERSION}"
|
||||
kapt "com.github.bumptech.glide:compiler:${GLIDE_VERSION}"
|
||||
api 'com.caverock:androidsvg-aar:1.4'
|
||||
|
||||
implementation "com.github.penfeizhou.android.animation:glide-plugin:3.0.5"
|
||||
implementation "com.github.bumptech.glide:avif-integration:${GLIDE_VERSION}"
|
||||
|
||||
api 'com.github.bumptech.glide:okhttp3-integration:4.11.0'
|
||||
api "com.squareup.okhttp3:okhttp:${expoModule.safeExtGet("okHttpVersion", '4.9.2')}"
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1'
|
||||
implementation "jp.wasabeef:glide-transformations:4.3.0"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# https://bumptech.github.io/glide/doc/download-setup.html#proguard
|
||||
|
||||
-keep public class * extends com.bumptech.glide.module.LibraryGlideModule
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep class * extends com.bumptech.glide.module.AppGlideModule {
|
||||
<init>(...);
|
||||
}
|
||||
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
}
|
||||
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
|
||||
*** rewind();
|
||||
}
|
||||
|
||||
-keep public class com.bumptech.glide.request.ThumbnailRequestCoordinator {
|
||||
*;
|
||||
}
|
||||
|
||||
-dontwarn com.bumptech.glide.load.resource.bitmap.VideoDecoder
|
||||
|
||||
# https://bumptech.github.io/glide/doc/configuration.html#applications
|
||||
|
||||
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
|
||||
|
||||
-keep public class com.bumptech.glide.integration.webp.WebpImage { *; }
|
||||
-keep public class com.bumptech.glide.integration.webp.WebpFrame { *; }
|
||||
-keep public class com.bumptech.glide.integration.webp.WebpBitmapFactory { *; }
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Begin Glide configuration -->
|
||||
<!-- Internet access (https://bumptech.github.io/glide/doc/download-setup.html#internet) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!--
|
||||
Allows Glide to monitor connectivity status and restart failed requests if users go from a
|
||||
a disconnected to a connected network state.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Local storage (https://bumptech.github.io/glide/doc/download-setup.html#local-storage) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<!-- End Glide configuration -->
|
||||
</manifest>
|
||||
Generated
Vendored
+125
@@ -0,0 +1,125 @@
|
||||
package com.caverock.androidsvg
|
||||
|
||||
import com.caverock.androidsvg.SVG.SPECIFIED_COLOR
|
||||
import com.caverock.androidsvg.SVG.SPECIFIED_FILL
|
||||
import com.caverock.androidsvg.SVG.SvgElementBase
|
||||
|
||||
internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) {
|
||||
if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) {
|
||||
paint.colour = newColor
|
||||
}
|
||||
}
|
||||
|
||||
internal fun replaceStyles(style: SVG.Style?, newColor: Int) {
|
||||
if (style == null) {
|
||||
return
|
||||
}
|
||||
|
||||
replaceColor(style.color, newColor)
|
||||
replaceColor(style.fill, newColor)
|
||||
replaceColor(style.stroke, newColor)
|
||||
replaceColor(style.stopColor, newColor)
|
||||
replaceColor(style.solidColor, newColor)
|
||||
replaceColor(style.viewportFill, newColor)
|
||||
}
|
||||
|
||||
internal fun hasStyle(element: SvgElementBase): Boolean {
|
||||
if (element.style == null && element.baseStyle == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val style = element.style
|
||||
val hasColorInStyle = style != null &&
|
||||
(
|
||||
style.color != null || style.fill != null || style.stroke != null ||
|
||||
style.stroke != null || style.stopColor != null || style.solidColor != null
|
||||
)
|
||||
|
||||
if (hasColorInStyle) {
|
||||
return true
|
||||
}
|
||||
|
||||
val baseStyle = element.baseStyle ?: return false
|
||||
return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null ||
|
||||
baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null
|
||||
}
|
||||
|
||||
internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) {
|
||||
if (hasStyle) {
|
||||
return
|
||||
}
|
||||
|
||||
val style = if (element.style != null) {
|
||||
element.style
|
||||
} else {
|
||||
SVG.Style().also {
|
||||
element.style = it
|
||||
}
|
||||
}
|
||||
|
||||
val color = SVG.Colour(newColor)
|
||||
when (element) {
|
||||
is SVG.Path,
|
||||
is SVG.Circle,
|
||||
is SVG.Ellipse,
|
||||
is SVG.Rect,
|
||||
is SVG.SolidColor,
|
||||
is SVG.Line,
|
||||
is SVG.Polygon,
|
||||
is SVG.PolyLine -> {
|
||||
style.apply {
|
||||
fill = color
|
||||
|
||||
specifiedFlags = SPECIFIED_FILL
|
||||
}
|
||||
}
|
||||
|
||||
is SVG.TextPath -> {
|
||||
style.apply {
|
||||
this.color = color
|
||||
|
||||
specifiedFlags = SPECIFIED_COLOR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) {
|
||||
// We want to keep the colors in the mask as they control the visibility of the element to which the mask is applied.
|
||||
if (element is SVG.Mask) {
|
||||
return
|
||||
}
|
||||
|
||||
val definesStyle = if (element is SvgElementBase) {
|
||||
val hasStyle = parentDefinesStyle || hasStyle(element)
|
||||
|
||||
replaceStyles(element.baseStyle, newColor)
|
||||
replaceStyles(element.style, newColor)
|
||||
defineStyles(element, newColor, hasStyle)
|
||||
|
||||
hasStyle
|
||||
} else {
|
||||
parentDefinesStyle
|
||||
}
|
||||
|
||||
if (element is SVG.SvgContainer) {
|
||||
for (child in element.children) {
|
||||
applyTintColor(child, newColor, definesStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyTintColor(svg: SVG, newColor: Int) {
|
||||
val root = svg.rootElement
|
||||
|
||||
svg.cssRules?.forEach { rule ->
|
||||
replaceStyles(rule.style, newColor)
|
||||
}
|
||||
replaceStyles(root.baseStyle, newColor)
|
||||
replaceStyles(root.style, newColor)
|
||||
val hasStyle = hasStyle(root)
|
||||
|
||||
for (child in root.children) {
|
||||
applyTintColor(child, newColor, hasStyle)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+226
@@ -0,0 +1,226 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.os.Build
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import expo.modules.image.enums.ContentFit
|
||||
import expo.modules.image.records.DecodeFormat
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Glide uses `hashCode` and `equals` of the `DownsampleStrategy` to calculate the cache key.
|
||||
* However, we generate this object dynamically, which means that each instance will be different.
|
||||
* Unfortunately, this behaviour is not correct since Glide will not load
|
||||
* the image from memory no matter what.
|
||||
* To fix this issue, we set the `hashCode` to a fixed number and
|
||||
* override `equals` to only check if objects have the common type.
|
||||
*/
|
||||
abstract class CustomDownsampleStrategy : DownsampleStrategy() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other is CustomDownsampleStrategy
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 302008237
|
||||
}
|
||||
}
|
||||
|
||||
object NoopDownsampleStrategy : DownsampleStrategy() {
|
||||
override fun getScaleFactor(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
requestedWidth: Int,
|
||||
requestedHeight: Int
|
||||
): Float = 1f
|
||||
|
||||
override fun getSampleSizeRounding(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
requestedWidth: Int,
|
||||
requestedHeight: Int
|
||||
): SampleSizeRounding = SampleSizeRounding.QUALITY
|
||||
}
|
||||
|
||||
class PlaceholderDownsampleStrategy(
|
||||
private val target: ImageViewWrapperTarget
|
||||
) : CustomDownsampleStrategy() {
|
||||
private var wasTriggered = false
|
||||
|
||||
override fun getScaleFactor(sourceWidth: Int, sourceHeight: Int, requestedWidth: Int, requestedHeight: Int): Float {
|
||||
if (!wasTriggered) {
|
||||
target.placeholderWidth = sourceWidth
|
||||
target.placeholderHeight = sourceHeight
|
||||
wasTriggered = true
|
||||
}
|
||||
return 1f
|
||||
}
|
||||
|
||||
override fun getSampleSizeRounding(sourceWidth: Int, sourceHeight: Int, requestedWidth: Int, requestedHeight: Int): SampleSizeRounding {
|
||||
return SampleSizeRounding.QUALITY
|
||||
}
|
||||
}
|
||||
|
||||
class ContentFitDownsampleStrategy(
|
||||
private val target: ImageViewWrapperTarget,
|
||||
private val contentFit: ContentFit
|
||||
) : CustomDownsampleStrategy() {
|
||||
private var wasTriggered = false
|
||||
override fun getScaleFactor(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
requestedWidth: Int,
|
||||
requestedHeight: Int
|
||||
): Float {
|
||||
// The method is invoked twice per asset, but we only need to preserve the original dimensions for the first call.
|
||||
// As Glide uses Android downsampling, it can only adjust dimensions by a factor of two,
|
||||
// and hence two distinct scaling factors are computed to achieve greater accuracy.
|
||||
if (!wasTriggered) {
|
||||
target.sourceWidth = sourceWidth
|
||||
target.sourceHeight = sourceHeight
|
||||
wasTriggered = true
|
||||
}
|
||||
|
||||
// The size of the container is unknown, we don't know what to do, so we just run the default scale.
|
||||
if (requestedWidth == Target.SIZE_ORIGINAL || requestedHeight == Target.SIZE_ORIGINAL) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
val aspectRation = calculateScaleFactor(
|
||||
sourceWidth.toFloat(),
|
||||
sourceHeight.toFloat(),
|
||||
requestedWidth.toFloat(),
|
||||
requestedHeight.toFloat()
|
||||
)
|
||||
|
||||
// We don't want to upscale the image
|
||||
return min(1f, aspectRation)
|
||||
}
|
||||
|
||||
private fun calculateScaleFactor(
|
||||
sourceWidth: Float,
|
||||
sourceHeight: Float,
|
||||
requestedWidth: Float,
|
||||
requestedHeight: Float
|
||||
): Float = when (contentFit) {
|
||||
ContentFit.Contain -> min(
|
||||
requestedWidth / sourceWidth,
|
||||
requestedHeight / sourceHeight
|
||||
)
|
||||
ContentFit.Cover -> java.lang.Float.max(
|
||||
requestedWidth / sourceWidth,
|
||||
requestedHeight / sourceHeight
|
||||
)
|
||||
ContentFit.ScaleDown -> if (requestedWidth < sourceWidth || requestedHeight < sourceHeight) {
|
||||
// The container is smaller than the image — scale it down and behave like `contain`
|
||||
min(
|
||||
requestedWidth / sourceWidth,
|
||||
requestedHeight / sourceHeight
|
||||
)
|
||||
} else {
|
||||
// The container is bigger than the image — don't scale it and behave like `none`
|
||||
1f
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
override fun getSampleSizeRounding(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
requestedWidth: Int,
|
||||
requestedHeight: Int
|
||||
) = SampleSizeRounding.QUALITY
|
||||
}
|
||||
|
||||
/**
|
||||
* Android has hardware bitmap size limit that can be drown on the canvas.
|
||||
* To prevents crashes, we need to downsample the image to fit into the maximum bitmap size.
|
||||
*/
|
||||
class SafeDownsampleStrategy(
|
||||
private val decodeFormat: DecodeFormat
|
||||
) : CustomDownsampleStrategy() {
|
||||
override fun getScaleFactor(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
requestedWidth: Int,
|
||||
requestedHeight: Int
|
||||
): Float {
|
||||
if (maxBitmapSize <= 0) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
val sourceSize = sourceWidth * sourceHeight * decodeFormat.toBytes()
|
||||
if (sourceSize <= maxBitmapSize) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
// We need to downsample the image to fit into the maximum bitmap size.
|
||||
// Calculate the aspect ratio of the source image. It's always <= 1.
|
||||
val srcAspectRatio = min(sourceWidth, sourceHeight).toDouble() / max(sourceWidth, sourceHeight).toDouble()
|
||||
// Calculate the area of the destination image.
|
||||
val dstArea = maxBitmapSize / decodeFormat.toBytes()
|
||||
// Calculate the longer side of the destination image using following formulas:
|
||||
// dstLongerSide * dstSmallerSide = dstArea
|
||||
// srcAspectRation * dstLongerSide = dstSmallerSide
|
||||
// after substitution:
|
||||
// srcAspectRation * dstLongerSide * dstLongerSide = dstArea
|
||||
// dstLongerSide = sqrt(dstArea / srcAspectRatio)
|
||||
val x = floor(sqrt(dstArea.toDouble() / srcAspectRatio)).toInt()
|
||||
|
||||
// Calculate the scale factor using longer side of both images.
|
||||
val scaleFactor = x.toDouble() / max(sourceWidth, sourceHeight).toDouble()
|
||||
|
||||
return scaleFactor.toFloat()
|
||||
}
|
||||
|
||||
override fun getSampleSizeRounding(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
requestedWidth: Int,
|
||||
requestedHeight: Int
|
||||
): SampleSizeRounding {
|
||||
return SampleSizeRounding.MEMORY
|
||||
}
|
||||
|
||||
private val maxBitmapSize by lazy {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return@lazy -1
|
||||
}
|
||||
|
||||
return@lazy try {
|
||||
val defaultSize = 100 * 1024 * 1024 // 100 MB - from `RecordingCanvas` src
|
||||
|
||||
// We're trying to get the value of `ro.hwui.max_texture_allocation_size` property
|
||||
// which is used by `RecordingCanvas` to determine the maximum bitmap size.
|
||||
@Suppress("PrivateApi")
|
||||
val getIntMethod = Class
|
||||
.forName("android.os.SystemProperties")
|
||||
.getMethod("getInt", String::class.java, Int::class.java)
|
||||
getIntMethod.isAccessible = true
|
||||
(getIntMethod.invoke(null, "ro.hwui.max_texture_allocation_size", defaultSize) as Int)
|
||||
.coerceAtLeast(defaultSize)
|
||||
} catch (e: Throwable) {
|
||||
// If something goes wrong we just return -1 and don't downsample the image.
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
if (other !is SafeDownsampleStrategy) {
|
||||
return false
|
||||
}
|
||||
|
||||
return decodeFormat == other.decodeFormat
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + decodeFormat.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
package expo.modules.image
|
||||
|
||||
import com.bumptech.glide.load.Option
|
||||
|
||||
object CustomOptions {
|
||||
// To pass the tint color to the SVG decoder, we need to wrap it in a custom Glide option.
|
||||
val tintColor = Option.memory<Int>("ExpoTintColor")
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package expo.modules.image
|
||||
|
||||
import expo.modules.kotlin.exception.CodedException
|
||||
|
||||
class ImageLoadFailed(exception: Exception) :
|
||||
CodedException(message = "Failed to load the image: ${exception.message}")
|
||||
Generated
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
|
||||
/**
|
||||
* We need to include an [AppGlideModule] for [GlideModule] annotations
|
||||
* to work.
|
||||
*/
|
||||
@GlideModule
|
||||
class ExpoImageAppGlideModule : AppGlideModule() {
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
super.applyOptions(context, builder)
|
||||
|
||||
builder.setLogLevel(
|
||||
if (BuildConfig.ALLOW_GLIDE_LOGS) {
|
||||
Log.VERBOSE
|
||||
} else {
|
||||
Log.ERROR
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.res.Configuration
|
||||
import expo.modules.image.blurhash.BlurhashDecoder
|
||||
|
||||
/**
|
||||
* Clears the Blurhash cache when the memory is low.
|
||||
*/
|
||||
object ExpoImageComponentCallbacks : ComponentCallbacks2 {
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
override fun onLowMemory() {
|
||||
BlurhashDecoder.clearCache()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
if (level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
|
||||
onLowMemory()
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+341
@@ -0,0 +1,341 @@
|
||||
@file:OptIn(EitherType::class)
|
||||
|
||||
package expo.modules.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Base64
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.toBitmapOrNull
|
||||
import androidx.core.view.doOnDetach
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.Headers
|
||||
import com.bumptech.glide.load.model.LazyHeaders
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.github.penfeizhou.animation.apng.APNGDrawable
|
||||
import com.github.penfeizhou.animation.gif.GifDrawable
|
||||
import com.github.penfeizhou.animation.webp.WebPDrawable
|
||||
import expo.modules.image.blurhash.BlurhashEncoder
|
||||
import expo.modules.image.enums.ContentFit
|
||||
import expo.modules.image.enums.Priority
|
||||
import expo.modules.image.records.CachePolicy
|
||||
import expo.modules.image.records.ContentPosition
|
||||
import expo.modules.image.records.DecodeFormat
|
||||
import expo.modules.image.records.DecodedSource
|
||||
import expo.modules.image.records.ImageLoadOptions
|
||||
import expo.modules.image.records.ImageTransition
|
||||
import expo.modules.image.records.SourceMap
|
||||
import expo.modules.image.thumbhash.ThumbhashEncoder
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.apifeatures.EitherType
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.functions.Coroutine
|
||||
import expo.modules.kotlin.functions.Queues
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.sharedobjects.SharedRef
|
||||
import expo.modules.kotlin.types.Either
|
||||
import expo.modules.kotlin.types.EitherOfThree
|
||||
import expo.modules.kotlin.types.toKClass
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URL
|
||||
|
||||
class ExpoImageModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoImage")
|
||||
|
||||
OnCreate {
|
||||
appContext.reactContext?.registerComponentCallbacks(ExpoImageComponentCallbacks)
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
appContext.reactContext?.unregisterComponentCallbacks(ExpoImageComponentCallbacks)
|
||||
}
|
||||
|
||||
AsyncFunction("prefetch") { urls: List<String>, cachePolicy: CachePolicy, headersMap: Map<String, String>?, promise: Promise ->
|
||||
val context = appContext.reactContext ?: return@AsyncFunction false
|
||||
|
||||
var imagesLoaded = 0
|
||||
var failed = false
|
||||
|
||||
val headers = headersMap?.let {
|
||||
LazyHeaders.Builder().apply {
|
||||
it.forEach { (key, value) ->
|
||||
addHeader(key, value)
|
||||
}
|
||||
}.build()
|
||||
} ?: Headers.DEFAULT
|
||||
|
||||
urls.forEach {
|
||||
Glide
|
||||
.with(context)
|
||||
.load(GlideUrl(it, headers)) // Use `load` instead of `download` to store the asset in the memory cache
|
||||
// We added `quality` and `downsample` to create the same cache key as in final image load.
|
||||
.encodeQuality(100)
|
||||
.downsample(NoopDownsampleStrategy)
|
||||
.customize(`when` = cachePolicy == CachePolicy.MEMORY) {
|
||||
diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
}
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
promise.resolve(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable>,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
imagesLoaded++
|
||||
|
||||
if (imagesLoaded == urls.size) {
|
||||
promise.resolve(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
.submit()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("loadAsync") Coroutine { source: SourceMap, options: ImageLoadOptions? ->
|
||||
ImageLoadTask(appContext, source, options ?: ImageLoadOptions()).load()
|
||||
}
|
||||
|
||||
suspend fun generatePlaceholder(
|
||||
source: Either<URL, Image>,
|
||||
encoder: (Bitmap) -> String
|
||||
): String {
|
||||
val image = source.let {
|
||||
if (it.`is`(Image::class)) {
|
||||
it.get(Image::class)
|
||||
} else {
|
||||
ImageLoadTask(appContext, SourceMap(uri = it.get(URL::class).toString()), ImageLoadOptions()).load()
|
||||
}
|
||||
}
|
||||
return withContext(Dispatchers.Default) {
|
||||
encoder(image.ref.toBitmap())
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("generateBlurhashAsync") Coroutine { source: Either<URL, Image>, numberOfComponents: Pair<Int, Int> ->
|
||||
generatePlaceholder(source) { bitmap ->
|
||||
BlurhashEncoder.encode(bitmap, numberOfComponents)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("generateThumbhashAsync") Coroutine { source: Either<URL, Image> ->
|
||||
generatePlaceholder(source) { bitmap ->
|
||||
Base64.encodeToString(
|
||||
ThumbhashEncoder.encode(bitmap),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Class(Image::class) {
|
||||
Property("width") { image: Image ->
|
||||
image.ref.intrinsicWidth
|
||||
}
|
||||
Property("height") { image: Image ->
|
||||
image.ref.intrinsicHeight
|
||||
}
|
||||
Property("scale") { image: Image ->
|
||||
// Not relying on `2x` in the filename, but want to make the following true:
|
||||
// If you multiply the logical size of the image by this value, you get the dimensions of the image in pixels.
|
||||
val screenDensity = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f
|
||||
(image.ref.toBitmapOrNull()?.density ?: 1) / (screenDensity * 160.0f)
|
||||
}
|
||||
Property("isAnimated") { image: Image ->
|
||||
if (image.ref is GifDrawable) {
|
||||
return@Property true
|
||||
}
|
||||
if (image.ref is APNGDrawable) {
|
||||
return@Property true
|
||||
}
|
||||
if (image.ref is WebPDrawable) {
|
||||
return@Property true
|
||||
}
|
||||
false
|
||||
}
|
||||
Property<Any?>("mediaType") { ->
|
||||
null // not easily supported on Android https://github.com/bumptech/glide/issues/1378#issuecomment-236879983
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("clearMemoryCache") {
|
||||
val activity = appContext.currentActivity ?: return@AsyncFunction false
|
||||
Glide.get(activity).clearMemory()
|
||||
return@AsyncFunction true
|
||||
}.runOnQueue(Queues.MAIN)
|
||||
|
||||
AsyncFunction<Boolean>("clearDiskCache") {
|
||||
val activity = appContext.currentActivity ?: return@AsyncFunction false
|
||||
activity.let {
|
||||
Glide.get(activity).clearDiskCache()
|
||||
}
|
||||
|
||||
return@AsyncFunction true
|
||||
}
|
||||
|
||||
AsyncFunction("getCachePathAsync") { cacheKey: String ->
|
||||
val context = appContext.reactContext ?: return@AsyncFunction null
|
||||
|
||||
val glideUrl = GlideUrl(cacheKey)
|
||||
val target = Glide.with(context).asFile().load(glideUrl).onlyRetrieveFromCache(true).submit()
|
||||
|
||||
return@AsyncFunction try {
|
||||
val file = target.get()
|
||||
file.absolutePath
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
View(ExpoImageViewWrapper::class) {
|
||||
Events(
|
||||
"onLoadStart",
|
||||
"onProgress",
|
||||
"onError",
|
||||
"onLoad",
|
||||
"onDisplay"
|
||||
)
|
||||
|
||||
Prop("source") { view: ExpoImageViewWrapper, sources: EitherOfThree<List<SourceMap>, SharedRef<Drawable>, SharedRef<Bitmap>>? ->
|
||||
if (sources == null) {
|
||||
view.sources = emptyList()
|
||||
return@Prop
|
||||
}
|
||||
|
||||
if (sources.`is`(toKClass<List<SourceMap>>())) {
|
||||
view.sources = sources.get(toKClass<List<SourceMap>>())
|
||||
return@Prop
|
||||
}
|
||||
|
||||
if (sources.`is`(toKClass<SharedRef<Drawable>>())) {
|
||||
val drawable = sources.get(toKClass<SharedRef<Drawable>>()).ref
|
||||
view.sources = listOf(DecodedSource(drawable))
|
||||
return@Prop
|
||||
}
|
||||
|
||||
val bitmap = sources.get(toKClass<SharedRef<Bitmap>>()).ref
|
||||
val context = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
val drawable = BitmapDrawable(context.resources, bitmap)
|
||||
view.sources = listOf(DecodedSource(drawable))
|
||||
}
|
||||
|
||||
Prop("contentFit") { view: ExpoImageViewWrapper, contentFit: ContentFit? ->
|
||||
view.contentFit = contentFit ?: ContentFit.Cover
|
||||
}
|
||||
|
||||
Prop("placeholderContentFit") { view: ExpoImageViewWrapper, placeholderContentFit: ContentFit? ->
|
||||
view.placeholderContentFit = placeholderContentFit ?: ContentFit.ScaleDown
|
||||
}
|
||||
|
||||
Prop("contentPosition") { view: ExpoImageViewWrapper, contentPosition: ContentPosition? ->
|
||||
view.contentPosition = contentPosition ?: ContentPosition.center
|
||||
}
|
||||
|
||||
Prop("blurRadius") { view: ExpoImageViewWrapper, blurRadius: Int? ->
|
||||
view.blurRadius = blurRadius?.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
Prop("transition") { view: ExpoImageViewWrapper, transition: ImageTransition? ->
|
||||
view.transition = transition
|
||||
}
|
||||
|
||||
Prop("tintColor") { view: ExpoImageViewWrapper, color: Int? ->
|
||||
view.tintColor = color
|
||||
}
|
||||
|
||||
Prop("placeholder") { view: ExpoImageViewWrapper, placeholder: List<SourceMap>? ->
|
||||
view.placeholders = placeholder ?: emptyList()
|
||||
}
|
||||
|
||||
Prop("accessible") { view: ExpoImageViewWrapper, accessible: Boolean? ->
|
||||
view.accessible = accessible == true
|
||||
}
|
||||
|
||||
Prop("accessibilityLabel") { view: ExpoImageViewWrapper, accessibilityLabel: String? ->
|
||||
view.accessibilityLabel = accessibilityLabel
|
||||
}
|
||||
|
||||
Prop("focusable") { view: ExpoImageViewWrapper, isFocusable: Boolean? ->
|
||||
view.isFocusableProp = isFocusable == true
|
||||
}
|
||||
|
||||
Prop("priority") { view: ExpoImageViewWrapper, priority: Priority? ->
|
||||
view.priority = priority ?: Priority.NORMAL
|
||||
}
|
||||
|
||||
Prop("cachePolicy") { view: ExpoImageViewWrapper, cachePolicy: CachePolicy? ->
|
||||
view.cachePolicy = cachePolicy ?: CachePolicy.DISK
|
||||
}
|
||||
|
||||
Prop("recyclingKey") { view: ExpoImageViewWrapper, recyclingKey: String? ->
|
||||
view.recyclingKey = recyclingKey
|
||||
}
|
||||
|
||||
Prop("allowDownscaling") { view: ExpoImageViewWrapper, allowDownscaling: Boolean? ->
|
||||
view.allowDownscaling = allowDownscaling != false
|
||||
}
|
||||
|
||||
Prop("autoplay") { view: ExpoImageViewWrapper, autoplay: Boolean? ->
|
||||
view.autoplay = autoplay != false
|
||||
}
|
||||
|
||||
Prop("decodeFormat") { view: ExpoImageViewWrapper, format: DecodeFormat? ->
|
||||
view.decodeFormat = format ?: DecodeFormat.ARGB_8888
|
||||
}
|
||||
|
||||
AsyncFunction("startAnimating") { view: ExpoImageViewWrapper ->
|
||||
view.setIsAnimating(true)
|
||||
}
|
||||
|
||||
AsyncFunction("stopAnimating") { view: ExpoImageViewWrapper ->
|
||||
view.setIsAnimating(false)
|
||||
}
|
||||
|
||||
AsyncFunction("lockResourceAsync") { view: ExpoImageViewWrapper ->
|
||||
view.lockResource = true
|
||||
}
|
||||
|
||||
AsyncFunction("unlockResourceAsync") { view: ExpoImageViewWrapper ->
|
||||
view.lockResource = false
|
||||
}
|
||||
|
||||
AsyncFunction("reloadAsync") { view: ExpoImageViewWrapper ->
|
||||
view.shouldRerender = true
|
||||
view.rerenderIfNeeded(force = true)
|
||||
}
|
||||
|
||||
OnViewDidUpdateProps { view: ExpoImageViewWrapper ->
|
||||
view.rerenderIfNeeded()
|
||||
}
|
||||
|
||||
OnViewDestroys { view: ExpoImageViewWrapper ->
|
||||
view.doOnDetach {
|
||||
view.onViewDestroys()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+129
@@ -0,0 +1,129 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.graphics.transform
|
||||
import androidx.core.view.isVisible
|
||||
import com.facebook.react.common.annotations.UnstableReactNativeAPI
|
||||
import expo.modules.image.enums.ContentFit
|
||||
import expo.modules.image.records.ContentPosition
|
||||
|
||||
@OptIn(UnstableReactNativeAPI::class)
|
||||
@SuppressLint("ViewConstructor")
|
||||
class ExpoImageView(
|
||||
context: Context
|
||||
) : AppCompatImageView(context) {
|
||||
var currentTarget: ImageViewWrapperTarget? = null
|
||||
var isPlaceholder: Boolean = false
|
||||
|
||||
fun recycleView(): ImageViewWrapperTarget? {
|
||||
setImageDrawable(null)
|
||||
|
||||
val target = currentTarget?.apply {
|
||||
isUsed = false
|
||||
}
|
||||
|
||||
currentTarget = null
|
||||
isVisible = false
|
||||
isPlaceholder = false
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
private var transformationMatrixChanged = false
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
applyTransformationMatrix()
|
||||
}
|
||||
|
||||
fun applyTransformationMatrix() {
|
||||
if (drawable == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaceholder) {
|
||||
applyTransformationMatrix(
|
||||
drawable,
|
||||
placeholderContentFit,
|
||||
sourceHeight = currentTarget?.placeholderHeight,
|
||||
sourceWidth = currentTarget?.placeholderWidth
|
||||
)
|
||||
} else {
|
||||
applyTransformationMatrix(drawable, contentFit, contentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTransformationMatrix(
|
||||
drawable: Drawable,
|
||||
contentFit: ContentFit,
|
||||
contentPosition: ContentPosition = ContentPosition.center,
|
||||
sourceWidth: Int? = currentTarget?.sourceWidth,
|
||||
sourceHeight: Int? = currentTarget?.sourceHeight
|
||||
) {
|
||||
val imageRect = RectF(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
|
||||
val viewRect = RectF(0f, 0f, width.toFloat(), height.toFloat())
|
||||
|
||||
val matrix = contentFit.toMatrix(
|
||||
imageRect,
|
||||
viewRect,
|
||||
sourceWidth ?: -1,
|
||||
sourceHeight ?: -1
|
||||
)
|
||||
val scaledImageRect = imageRect.transform(matrix)
|
||||
|
||||
imageMatrix = matrix.apply {
|
||||
contentPosition.apply(this, scaledImageRect, viewRect)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
scaleType = ScaleType.MATRIX
|
||||
}
|
||||
|
||||
// region Component Props
|
||||
internal var contentFit: ContentFit = ContentFit.Cover
|
||||
set(value) {
|
||||
field = value
|
||||
transformationMatrixChanged = true
|
||||
}
|
||||
|
||||
internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown
|
||||
set(value) {
|
||||
field = value
|
||||
transformationMatrixChanged = true
|
||||
}
|
||||
|
||||
internal var contentPosition: ContentPosition = ContentPosition.center
|
||||
set(value) {
|
||||
field = value
|
||||
transformationMatrixChanged = true
|
||||
}
|
||||
|
||||
internal fun setTintColor(color: Int?) {
|
||||
color?.let { setColorFilter(it, PorterDuff.Mode.SRC_IN) } ?: clearColorFilter()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
// If we encounter a recycled bitmap here, it suggests an issue where we may have failed to
|
||||
// finish clearing the image bitmap before the UI attempts to display it.
|
||||
// One solution could be to suppress the error and assume that the second image view is currently responsible for displaying the correct view.
|
||||
if ((drawable as? BitmapDrawable)?.bitmap?.isRecycled == true) {
|
||||
Log.e("ExpoImage", "Trying to use a recycled bitmap")
|
||||
recycleView()?.let { target ->
|
||||
(parent as? ExpoImageViewWrapper)?.requestManager?.let { requestManager ->
|
||||
target.clear(requestManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
super.draw(canvas)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+647
@@ -0,0 +1,647 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.AccessibilityDelegateCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.github.penfeizhou.animation.gif.GifDrawable
|
||||
import expo.modules.image.enums.ContentFit
|
||||
import expo.modules.image.enums.Priority
|
||||
import expo.modules.image.events.GlideRequestListener
|
||||
import expo.modules.image.events.OkHttpProgressListener
|
||||
import expo.modules.image.okhttp.GlideUrlWrapper
|
||||
import expo.modules.image.records.CachePolicy
|
||||
import expo.modules.image.records.ContentPosition
|
||||
import expo.modules.image.records.DecodeFormat
|
||||
import expo.modules.image.records.ImageErrorEvent
|
||||
import expo.modules.image.records.ImageLoadEvent
|
||||
import expo.modules.image.records.ImageProgressEvent
|
||||
import expo.modules.image.records.ImageTransition
|
||||
import expo.modules.image.records.Source
|
||||
import expo.modules.image.svg.SVGPictureDrawable
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.tracing.beginAsyncTraceBlock
|
||||
import expo.modules.kotlin.tracing.trace
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
import jp.wasabeef.glide.transformations.BlurTransformation
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
private val activity: Activity
|
||||
get() = appContext.throwingActivity
|
||||
|
||||
internal val requestManager = getOrCreateRequestManager(appContext, activity)
|
||||
private val progressListener = OkHttpProgressListener(WeakReference(this))
|
||||
|
||||
private val firstView = ExpoImageView(activity)
|
||||
private val secondView = ExpoImageView(activity)
|
||||
|
||||
private val mainHandler = Handler(context.mainLooper)
|
||||
|
||||
/**
|
||||
* @returns the view which is currently active or will be used when both views are empty
|
||||
*/
|
||||
private val activeView: ExpoImageView
|
||||
get() {
|
||||
if (secondView.drawable != null) {
|
||||
return secondView
|
||||
}
|
||||
return firstView
|
||||
}
|
||||
|
||||
private var firstTarget = ImageViewWrapperTarget(WeakReference(this))
|
||||
private var secondTarget = ImageViewWrapperTarget(WeakReference(this))
|
||||
|
||||
internal val onLoadStart by EventDispatcher<Unit>()
|
||||
internal val onProgress by EventDispatcher<ImageProgressEvent>()
|
||||
internal val onError by EventDispatcher<ImageErrorEvent>()
|
||||
internal val onLoad by EventDispatcher<ImageLoadEvent>()
|
||||
internal val onDisplay by EventDispatcher<Unit>()
|
||||
|
||||
internal var sources: List<Source> = emptyList()
|
||||
private val bestSource: Source?
|
||||
get() = getBestSource(sources)
|
||||
|
||||
internal var placeholders: List<Source> = emptyList()
|
||||
private val bestPlaceholder: Source?
|
||||
get() = getBestSource(placeholders)
|
||||
|
||||
internal var blurRadius: Int? = null
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
shouldRerender = true
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
internal var transition: ImageTransition? = null
|
||||
|
||||
internal var contentFit: ContentFit = ContentFit.Cover
|
||||
set(value) {
|
||||
field = value
|
||||
activeView.contentFit = value
|
||||
transformationMatrixChanged = true
|
||||
}
|
||||
|
||||
internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown
|
||||
set(value) {
|
||||
field = value
|
||||
activeView.placeholderContentFit = value
|
||||
transformationMatrixChanged = true
|
||||
}
|
||||
|
||||
internal var contentPosition: ContentPosition = ContentPosition.center
|
||||
set(value) {
|
||||
field = value
|
||||
activeView.contentPosition = value
|
||||
transformationMatrixChanged = true
|
||||
}
|
||||
|
||||
internal var tintColor: Int? = null
|
||||
set(value) {
|
||||
field = value
|
||||
// To apply the tint color to the SVG, we need to recreate the drawable.
|
||||
if (activeView.drawable is SVGPictureDrawable) {
|
||||
shouldRerender = true
|
||||
} else {
|
||||
activeView.setTintColor(value)
|
||||
}
|
||||
}
|
||||
|
||||
internal var isFocusableProp: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
activeView.isFocusable = value
|
||||
}
|
||||
|
||||
internal var accessible: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
setIsScreenReaderFocusable(activeView, value)
|
||||
}
|
||||
|
||||
internal var accessibilityLabel: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
activeView.contentDescription = accessibilityLabel
|
||||
}
|
||||
|
||||
var recyclingKey: String? = null
|
||||
set(value) {
|
||||
clearViewBeforeChangingSource = field != null && value != null && value != field
|
||||
field = value
|
||||
}
|
||||
|
||||
internal var allowDownscaling: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
shouldRerender = true
|
||||
}
|
||||
|
||||
internal var decodeFormat: DecodeFormat = DecodeFormat.ARGB_8888
|
||||
set(value) {
|
||||
field = value
|
||||
shouldRerender = true
|
||||
}
|
||||
|
||||
internal var autoplay: Boolean = true
|
||||
|
||||
internal var lockResource: Boolean = false
|
||||
|
||||
internal var priority: Priority = Priority.NORMAL
|
||||
internal var cachePolicy: CachePolicy = CachePolicy.DISK
|
||||
|
||||
fun setIsAnimating(setAnimating: Boolean) {
|
||||
// Animatable animations always start from the beginning when resumed.
|
||||
// So we check first if the resource is a GifDrawable, because it can continue
|
||||
// from where it was paused.
|
||||
when (val resource = activeView.drawable) {
|
||||
is GifDrawable -> setIsAnimating(resource, setAnimating)
|
||||
is Animatable -> setIsAnimating(resource, setAnimating)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setIsAnimating(resource: GifDrawable, setAnimating: Boolean) {
|
||||
if (setAnimating) {
|
||||
if (resource.isPaused) {
|
||||
resource.resume()
|
||||
} else {
|
||||
resource.start()
|
||||
}
|
||||
} else {
|
||||
resource.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setIsAnimating(resource: Animatable, setAnimating: Boolean) {
|
||||
if (setAnimating) {
|
||||
resource.start()
|
||||
} else {
|
||||
resource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the image should be loaded again
|
||||
*/
|
||||
internal var shouldRerender = false
|
||||
|
||||
/**
|
||||
* Currently loaded source
|
||||
*/
|
||||
private var loadedSource: GlideModelProvider? = null
|
||||
|
||||
/**
|
||||
* Whether the transformation matrix should be reapplied
|
||||
*/
|
||||
private var transformationMatrixChanged = false
|
||||
|
||||
/**
|
||||
* Whether the view content should be cleared to blank when the source was changed.
|
||||
*/
|
||||
private var clearViewBeforeChangingSource = false
|
||||
|
||||
/**
|
||||
* Copies saved props to the provided view.
|
||||
* It ensures that the view state is up to date.
|
||||
*/
|
||||
private fun copyProps(view: ExpoImageView) {
|
||||
view.contentFit = contentFit
|
||||
view.contentPosition = contentPosition
|
||||
view.setTintColor(tintColor)
|
||||
view.isFocusable = isFocusableProp
|
||||
view.contentDescription = accessibilityLabel
|
||||
setIsScreenReaderFocusable(view, accessible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows `isScreenReaderFocusable` to be set on apis below level 28
|
||||
*/
|
||||
private fun setIsScreenReaderFocusable(view: View, value: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
view.isScreenReaderFocusable = value
|
||||
} else {
|
||||
ViewCompat.setAccessibilityDelegate(
|
||||
this,
|
||||
object : AccessibilityDelegateCompat() {
|
||||
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
|
||||
info.isScreenReaderFocusable = value
|
||||
super.onInitializeAccessibilityNodeInfo(host, info)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a new resource is available, this method tries to handle it.
|
||||
* It decides where provided bitmap should be displayed and clears the previous target/image.
|
||||
*/
|
||||
fun onResourceReady(
|
||||
target: ImageViewWrapperTarget,
|
||||
resource: Drawable,
|
||||
isPlaceholder: Boolean = false
|
||||
) =
|
||||
// The "onResourceReady" function will be triggered when the new resource is available by the Glide.
|
||||
// According to the Glide documentation (https://bumptech.github.io/glide/doc/debugging.html#you-cant-start-or-clear-loads-in-requestlistener-or-target-callbacks),
|
||||
// it's not advisable to clear the Glide target within the stack frame.
|
||||
// To avoid this, a new runnable is posted to the front of the main queue, which can then clean or create targets.
|
||||
// This ensures that the "onResourceReady" frame of the Glide code will be discarded, and the internal state can be altered once again.
|
||||
// Normally, using "postAtFrontOfQueue" can lead to issues such as message queue starvation, ordering problems, and other unexpected consequences.
|
||||
// However, in this case, it is safe to use as long as nothing else is added to the queue.
|
||||
// The intention is simply to wait for the Glide code to finish before the content of the underlying views is changed during the same rendering tick.
|
||||
mainHandler.postAtFrontOfQueue {
|
||||
trace(Trace.tag, "onResourceReady") {
|
||||
val transitionDuration = (transition?.duration ?: 0).toLong()
|
||||
|
||||
// If provided resource is a placeholder, but the target doesn't have a source, we treat it as a normal image.
|
||||
if (!isPlaceholder || !target.hasSource) {
|
||||
val (newView, previousView) = if (firstView.drawable == null) {
|
||||
firstView to secondView
|
||||
} else {
|
||||
secondView to firstView
|
||||
}
|
||||
|
||||
val clearPreviousView = {
|
||||
previousView
|
||||
.recycleView()
|
||||
?.apply {
|
||||
// When the placeholder is loaded, one target is displayed in both views.
|
||||
// So we just have to move the reference to a new view instead of clearing the target.
|
||||
if (this != target) {
|
||||
clear(requestManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configureView(newView, target, resource, isPlaceholder)
|
||||
|
||||
// Dispatch "onDisplay" event only for the main source (no placeholder).
|
||||
if (target.hasSource) {
|
||||
onDisplay.invoke(Unit)
|
||||
}
|
||||
|
||||
if (transitionDuration <= 0) {
|
||||
clearPreviousView()
|
||||
newView.alpha = 1f
|
||||
newView.bringToFront()
|
||||
} else {
|
||||
newView.bringToFront()
|
||||
previousView.alpha = 1f
|
||||
newView.alpha = 0f
|
||||
previousView.animate().apply {
|
||||
duration = transitionDuration
|
||||
alpha(0f)
|
||||
withEndAction {
|
||||
clearPreviousView()
|
||||
}
|
||||
}
|
||||
newView.animate().apply {
|
||||
duration = transitionDuration
|
||||
alpha(1f)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We don't want to show the placeholder if something is currently displayed.
|
||||
// There is one exception - when we're displaying a different placeholder.
|
||||
if ((firstView.drawable != null && !firstView.isPlaceholder) || secondView.drawable != null) {
|
||||
return@trace
|
||||
}
|
||||
|
||||
firstView
|
||||
.recycleView()
|
||||
?.apply {
|
||||
// The current target is already bound to the view. We don't want to cancel it in that case.
|
||||
if (this != target) {
|
||||
clear(requestManager)
|
||||
}
|
||||
}
|
||||
|
||||
configureView(firstView, target, resource, isPlaceholder)
|
||||
if (transitionDuration > 0) {
|
||||
firstView.bringToFront()
|
||||
firstView.alpha = 0f
|
||||
secondView.isVisible = false
|
||||
firstView.animate().apply {
|
||||
duration = transitionDuration
|
||||
alpha(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If our image is animated, we want to see if autoplay is disabled. If it is, we should
|
||||
// stop the animation as soon as the resource is ready. Placeholders should not follow this
|
||||
// value since the intention is almost certainly to display the animation (i.e. a spinner)
|
||||
if (resource is Animatable && !isPlaceholder && !autoplay) {
|
||||
resource.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureView(
|
||||
view: ExpoImageView,
|
||||
target: ImageViewWrapperTarget,
|
||||
resource: Drawable,
|
||||
isPlaceholder: Boolean
|
||||
) {
|
||||
view.let {
|
||||
it.setImageDrawable(resource)
|
||||
|
||||
it.isPlaceholder = isPlaceholder
|
||||
it.placeholderContentFit = target.placeholderContentFit ?: ContentFit.ScaleDown
|
||||
copyProps(it)
|
||||
|
||||
it.isVisible = true
|
||||
|
||||
it.currentTarget = target
|
||||
|
||||
// The view isn't layout when it's invisible.
|
||||
// Therefore, we have to set the correct size manually.
|
||||
it.layout(0, 0, width, height)
|
||||
|
||||
it.applyTransformationMatrix()
|
||||
}
|
||||
target.isUsed = true
|
||||
|
||||
if (resource is Animatable) {
|
||||
resource.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBestSource(sources: List<Source>): Source? {
|
||||
if (sources.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (sources.size == 1) {
|
||||
return sources.first()
|
||||
}
|
||||
|
||||
val targetPixelCount = width * height
|
||||
if (targetPixelCount == 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
var bestSource: Source? = null
|
||||
var bestFit = Double.MAX_VALUE
|
||||
|
||||
sources.forEach {
|
||||
val fit = abs(1 - (it.pixelCount / targetPixelCount))
|
||||
if (fit < bestFit) {
|
||||
bestFit = fit
|
||||
bestSource = it
|
||||
}
|
||||
}
|
||||
|
||||
return bestSource
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
rerenderIfNeeded(
|
||||
shouldRerenderBecauseOfResize = allowDownscaling &&
|
||||
contentFit != ContentFit.Fill &&
|
||||
contentFit != ContentFit.None
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPropOptions(): RequestOptions {
|
||||
return RequestOptions()
|
||||
.priority(this@ExpoImageViewWrapper.priority.toGlidePriority())
|
||||
.customize(`when` = cachePolicy != CachePolicy.MEMORY_AND_DISK && cachePolicy != CachePolicy.MEMORY) {
|
||||
skipMemoryCache(true)
|
||||
}
|
||||
.customize(`when` = cachePolicy == CachePolicy.NONE || cachePolicy == CachePolicy.MEMORY) {
|
||||
diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
}
|
||||
.customize(blurRadius) {
|
||||
transform(BlurTransformation(min(it, 25), 4))
|
||||
}
|
||||
}
|
||||
|
||||
fun onViewDestroys() {
|
||||
firstView.setImageDrawable(null)
|
||||
secondView.setImageDrawable(null)
|
||||
|
||||
requestManager.clear(firstTarget)
|
||||
requestManager.clear(secondTarget)
|
||||
}
|
||||
|
||||
private fun cleanIfNeeded(
|
||||
newBestSource: Source?,
|
||||
newBestSourceModel: GlideModelProvider?,
|
||||
newBestPlaceholderModel: GlideModelProvider?
|
||||
): Boolean {
|
||||
// We only clean the image when the source is set to null and we don't have a placeholder or the view is empty.
|
||||
if (width == 0 || height == 0 || (newBestSource == null || newBestSourceModel == null) && newBestPlaceholderModel == null) {
|
||||
firstView.recycleView()
|
||||
secondView.recycleView()
|
||||
|
||||
requestManager.clear(firstTarget)
|
||||
requestManager.clear(secondTarget)
|
||||
|
||||
shouldRerender = false
|
||||
loadedSource = null
|
||||
transformationMatrixChanged = false
|
||||
clearViewBeforeChangingSource = false
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun createDownsampleStrategy(target: ImageViewWrapperTarget): DownsampleStrategy {
|
||||
return if (!allowDownscaling) {
|
||||
DownsampleStrategy.NONE
|
||||
} else if (
|
||||
contentFit != ContentFit.Fill &&
|
||||
contentFit != ContentFit.None
|
||||
) {
|
||||
ContentFitDownsampleStrategy(target, contentFit)
|
||||
} else {
|
||||
// it won't downscale the image if the image is smaller than hardware bitmap size limit
|
||||
SafeDownsampleStrategy(decodeFormat)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearViewBeforeChangingSource() {
|
||||
if (clearViewBeforeChangingSource) {
|
||||
val activeView = if (firstView.drawable != null) {
|
||||
firstView
|
||||
} else {
|
||||
secondView
|
||||
}
|
||||
|
||||
activeView
|
||||
.recycleView()
|
||||
?.apply {
|
||||
clear(requestManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun rerenderIfNeeded(shouldRerenderBecauseOfResize: Boolean = false, force: Boolean = false) =
|
||||
trace(Trace.tag, "rerenderIfNeeded(shouldRerenderBecauseOfResize=$shouldRerenderBecauseOfResize,force=$force)") {
|
||||
if (lockResource && !force) {
|
||||
return@trace
|
||||
}
|
||||
val bestSource = bestSource
|
||||
val bestPlaceholder = bestPlaceholder
|
||||
|
||||
val sourceToLoad = bestSource?.createGlideModelProvider(context)
|
||||
val placeholder = bestPlaceholder?.createGlideModelProvider(context)
|
||||
|
||||
if (cleanIfNeeded(bestSource, sourceToLoad, placeholder)) {
|
||||
// the view was cleaned
|
||||
return@trace
|
||||
}
|
||||
|
||||
val shouldRerender = sourceToLoad != loadedSource || shouldRerender || (sourceToLoad == null && placeholder != null)
|
||||
if (!shouldRerender && !shouldRerenderBecauseOfResize) {
|
||||
// In the case where the source didn't change, but the transformation matrix has to be
|
||||
// recalculated, we can apply the new transformation right away.
|
||||
// When the source and the matrix is different, we don't want to do anything.
|
||||
// We don't want to changed the transformation of the currently displayed image.
|
||||
// The new matrix will be applied when new resource is loaded.
|
||||
if (transformationMatrixChanged) {
|
||||
activeView.applyTransformationMatrix()
|
||||
}
|
||||
|
||||
transformationMatrixChanged = false
|
||||
clearViewBeforeChangingSource = false
|
||||
|
||||
return@trace
|
||||
}
|
||||
|
||||
clearViewBeforeChangingSource()
|
||||
|
||||
this.shouldRerender = false
|
||||
loadedSource = sourceToLoad
|
||||
val options = bestSource?.createGlideOptions(context)
|
||||
val propOptions = createPropOptions()
|
||||
|
||||
val model = sourceToLoad?.getGlideModel()
|
||||
if (model is GlideUrlWrapper) {
|
||||
model.progressListener = progressListener
|
||||
}
|
||||
|
||||
onLoadStart.invoke(Unit)
|
||||
val newTarget = if (secondTarget.isUsed) {
|
||||
firstTarget
|
||||
} else {
|
||||
secondTarget
|
||||
}
|
||||
newTarget.hasSource = sourceToLoad != null
|
||||
|
||||
val downsampleStrategy = createDownsampleStrategy(newTarget)
|
||||
|
||||
val request = requestManager
|
||||
.asDrawable()
|
||||
.load(model)
|
||||
.customize(bestPlaceholder, placeholder) { placeholderSource, placeholderModel ->
|
||||
val newPlaceholderContentFit = if (!placeholderSource.usesPlaceholderContentFit()) {
|
||||
contentFit
|
||||
} else {
|
||||
placeholderContentFit
|
||||
}
|
||||
newTarget.placeholderContentFit = newPlaceholderContentFit
|
||||
|
||||
thumbnail(
|
||||
requestManager.load(placeholderModel.getGlideModel())
|
||||
.downsample(PlaceholderDownsampleStrategy(newTarget))
|
||||
.apply(placeholderSource.createGlideOptions(context))
|
||||
)
|
||||
}
|
||||
.downsample(downsampleStrategy)
|
||||
.addListener(GlideRequestListener(WeakReference(this)))
|
||||
.encodeQuality(100)
|
||||
.format(decodeFormat.toGlideFormat())
|
||||
.apply(propOptions)
|
||||
.apply(options)
|
||||
.customize(tintColor) {
|
||||
apply(RequestOptions().set(CustomOptions.tintColor, it))
|
||||
}
|
||||
|
||||
val cookie = Trace.getNextCookieValue()
|
||||
beginAsyncTraceBlock(Trace.tag, Trace.loadNewImageBlock, cookie)
|
||||
newTarget.setCookie(cookie)
|
||||
request.into(newTarget)
|
||||
|
||||
transformationMatrixChanged = false
|
||||
clearViewBeforeChangingSource = false
|
||||
}
|
||||
|
||||
init {
|
||||
val matchParent = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
|
||||
layoutParams = matchParent
|
||||
|
||||
firstView.isVisible = true
|
||||
secondView.isVisible = true
|
||||
|
||||
// We need to add a `FrameLayout` to allow views to overflow.
|
||||
// With the `LinearLayout` is impossible to render two views on each other.
|
||||
val layout = FrameLayout(context).apply {
|
||||
layoutParams = matchParent
|
||||
addView(
|
||||
firstView,
|
||||
matchParent
|
||||
)
|
||||
addView(
|
||||
secondView,
|
||||
matchParent
|
||||
)
|
||||
}
|
||||
|
||||
addView(layout, matchParent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var requestManager: RequestManager? = null
|
||||
private var appContextRef: WeakReference<AppContext?> = WeakReference(null)
|
||||
private var activityRef: WeakReference<Activity?> = WeakReference(null)
|
||||
|
||||
fun getOrCreateRequestManager(
|
||||
appContext: AppContext,
|
||||
activity: Activity
|
||||
): RequestManager = synchronized(Companion) {
|
||||
val cachedRequestManager = requestManager
|
||||
?: return createNewRequestManager(activity).also {
|
||||
requestManager = it
|
||||
appContextRef = WeakReference(appContext)
|
||||
activityRef = WeakReference(activity)
|
||||
}
|
||||
|
||||
// Request manager was created using different activity or app context
|
||||
if (appContextRef.get() != appContext || activityRef.get() != activity) {
|
||||
return createNewRequestManager(activity).also {
|
||||
requestManager = it
|
||||
appContextRef = WeakReference(appContext)
|
||||
activityRef = WeakReference(activity)
|
||||
}
|
||||
}
|
||||
|
||||
return cachedRequestManager
|
||||
}
|
||||
|
||||
private fun createNewRequestManager(activity: Activity): RequestManager = Glide.with(activity)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
package expo.modules.image
|
||||
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
|
||||
/**
|
||||
* Conditionally applies the block to the RequestBuilder if the condition is true.
|
||||
*/
|
||||
fun <T> RequestBuilder<T>.customize(`when`: Boolean, block: RequestBuilder<T>.() -> RequestBuilder<T>): RequestBuilder<T> {
|
||||
if (!`when`) {
|
||||
return this
|
||||
}
|
||||
|
||||
return block()
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies the block to the RequestBuilder if the value is not null.
|
||||
*/
|
||||
inline fun <T, P> RequestBuilder<T>.customize(value: P?, block: RequestBuilder<T>.(P) -> RequestBuilder<T>): RequestBuilder<T> {
|
||||
if (value == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
return block(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies the block to the RequestBuilder if both values aren't null.
|
||||
*/
|
||||
inline fun <T, P1, P2> RequestBuilder<T>.customize(first: P1?, second: P2?, block: RequestBuilder<T>.(P1, P2) -> RequestBuilder<T>): RequestBuilder<T> {
|
||||
if (first == null || second == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
return block(first, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies the block to the RequestOptions if the condition is true.
|
||||
*/
|
||||
inline fun RequestOptions.customize(`when`: Boolean, block: RequestOptions.() -> RequestOptions): RequestOptions {
|
||||
if (!`when`) {
|
||||
return this
|
||||
}
|
||||
|
||||
return block()
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies the block to the RequestOptions if the value is not null.
|
||||
*/
|
||||
inline fun <T> RequestOptions.customize(value: T?, block: RequestOptions.(T) -> RequestOptions): RequestOptions {
|
||||
if (value == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
return block(value)
|
||||
}
|
||||
|
||||
fun <T> RequestBuilder<T>.apply(options: RequestOptions?): RequestBuilder<T> {
|
||||
if (options == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
return apply(options)
|
||||
}
|
||||
Generated
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import expo.modules.image.blurhash.BlurhashModel
|
||||
import expo.modules.image.decodedsource.DecodedModel
|
||||
import expo.modules.image.okhttp.GlideUrlWrapper
|
||||
import expo.modules.image.thumbhash.ThumbhashModel
|
||||
|
||||
fun interface GlideModelProvider {
|
||||
fun getGlideModel(): Any
|
||||
}
|
||||
|
||||
data class DecodedModelProvider(
|
||||
private val drawable: Drawable
|
||||
) : GlideModelProvider {
|
||||
override fun getGlideModel() = DecodedModel(drawable)
|
||||
}
|
||||
|
||||
data class UrlModelProvider(
|
||||
private val glideUrl: GlideUrl
|
||||
) : GlideModelProvider {
|
||||
override fun getGlideModel() = GlideUrlWrapper(glideUrl)
|
||||
}
|
||||
|
||||
data class RawModelProvider(
|
||||
private val data: String
|
||||
) : GlideModelProvider {
|
||||
override fun getGlideModel() = data
|
||||
}
|
||||
|
||||
data class UriModelProvider(
|
||||
private val uri: Uri
|
||||
) : GlideModelProvider {
|
||||
override fun getGlideModel() = uri
|
||||
}
|
||||
|
||||
data class BlurhashModelProvider(
|
||||
private val uri: Uri,
|
||||
private val width: Int,
|
||||
private val height: Int
|
||||
) : GlideModelProvider {
|
||||
override fun getGlideModel() = BlurhashModel(uri, width, height)
|
||||
}
|
||||
|
||||
data class ThumbhashModelProvider(
|
||||
private val uri: Uri
|
||||
) : GlideModelProvider {
|
||||
override fun getGlideModel() = ThumbhashModel(uri)
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import expo.modules.kotlin.sharedobjects.SharedRef
|
||||
|
||||
class Image(ref: Drawable) : SharedRef<Drawable>(ref) {
|
||||
override val nativeRefType: String = "image"
|
||||
|
||||
override fun getAdditionalMemoryPressure(): Int {
|
||||
val ref = ref
|
||||
if (ref is BitmapDrawable) {
|
||||
return ref.getBitmap().allocationByteCount
|
||||
}
|
||||
|
||||
// We can't get the size in bytes of the drawable.
|
||||
// Let's just return the size in pixels for now.
|
||||
return ref.intrinsicWidth * ref.intrinsicHeight
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
package expo.modules.image
|
||||
|
||||
import com.bumptech.glide.Glide
|
||||
import expo.modules.image.records.ImageLoadOptions
|
||||
import expo.modules.image.records.SourceMap
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
open class ImageLoadTask(
|
||||
private val appContext: AppContext,
|
||||
private val source: SourceMap,
|
||||
private val options: ImageLoadOptions
|
||||
) {
|
||||
suspend fun load(): Image {
|
||||
val context =
|
||||
this@ImageLoadTask.appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
|
||||
val sourceToLoad = source.createGlideModelProvider(context)
|
||||
val model = sourceToLoad?.getGlideModel()
|
||||
|
||||
try {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
Glide
|
||||
.with(context)
|
||||
.asDrawable()
|
||||
.load(model)
|
||||
.centerInside()
|
||||
.submit(options.maxWidth, options.maxHeight)
|
||||
.get()
|
||||
}
|
||||
|
||||
return Image(bitmap)
|
||||
} catch (e: Exception) {
|
||||
throw ImageLoadFailed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.graphics.RectF
|
||||
|
||||
fun calcXTranslation(
|
||||
value: Float,
|
||||
imageRect: RectF,
|
||||
viewRect: RectF,
|
||||
isPercentage: Boolean = false,
|
||||
isReverse: Boolean = false
|
||||
): Float = calcTranslation(value, imageRect.width(), viewRect.width(), isPercentage, isReverse)
|
||||
|
||||
fun calcYTranslation(
|
||||
value: Float,
|
||||
imageRect: RectF,
|
||||
viewRect: RectF,
|
||||
isPercentage: Boolean = false,
|
||||
isReverse: Boolean = false
|
||||
): Float = calcTranslation(value, imageRect.height(), viewRect.height(), isPercentage, isReverse)
|
||||
|
||||
fun calcTranslation(
|
||||
value: Float,
|
||||
imageRefValue: Float,
|
||||
viewRefValue: Float,
|
||||
isPercentage: Boolean = false,
|
||||
isReverse: Boolean = false
|
||||
): Float {
|
||||
if (isPercentage) {
|
||||
val finalPercentage = if (isReverse) {
|
||||
100f - value
|
||||
} else {
|
||||
value
|
||||
}
|
||||
return (finalPercentage / 100f) * (viewRefValue - imageRefValue)
|
||||
}
|
||||
|
||||
if (isReverse) {
|
||||
return viewRefValue - imageRefValue - value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
Generated
Vendored
+350
@@ -0,0 +1,350 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Point
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.request.Request
|
||||
import com.bumptech.glide.request.ThumbnailRequestCoordinator
|
||||
import com.bumptech.glide.request.target.SizeReadyCallback
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.bumptech.glide.util.Preconditions
|
||||
import com.bumptech.glide.util.Synthetic
|
||||
import expo.modules.core.utilities.ifNull
|
||||
import expo.modules.image.enums.ContentFit
|
||||
import expo.modules.kotlin.tracing.endAsyncTraceBlock
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* A custom target to provide a smooth transition between multiple drawables.
|
||||
* It delegates images to the [ExpoImageViewWrapper], where we handle the loaded [Drawable].
|
||||
* When the target is cleared, we don't do anything. The [ExpoImageViewWrapper] is responsible for
|
||||
* clearing bitmaps before freeing targets. That may be error-prone, but that is the only way
|
||||
* of implementing the transition between bitmaps.
|
||||
*/
|
||||
class ImageViewWrapperTarget(
|
||||
private val imageViewHolder: WeakReference<ExpoImageViewWrapper>
|
||||
) : Target<Drawable> {
|
||||
/**
|
||||
* Whether the target has a main, non-placeholder source
|
||||
*/
|
||||
var hasSource = false
|
||||
|
||||
/**
|
||||
* Whether the target is used - the asset loaded by it has been drawn in the image view
|
||||
*/
|
||||
var isUsed = false
|
||||
|
||||
/**
|
||||
* The main source height where -1 means unknown
|
||||
*/
|
||||
var sourceHeight = -1
|
||||
|
||||
/**
|
||||
* The main source width where -1 means unknown
|
||||
*/
|
||||
var sourceWidth = -1
|
||||
|
||||
/**
|
||||
* The placeholder height where -1 means unknown
|
||||
*/
|
||||
var placeholderHeight = -1
|
||||
|
||||
/**
|
||||
* The placeholder width where -1 means unknown
|
||||
*/
|
||||
var placeholderWidth = -1
|
||||
|
||||
private var cookie = -1
|
||||
|
||||
fun setCookie(newValue: Int) {
|
||||
endLoadingNewImageTraceBlock()
|
||||
synchronized(this) {
|
||||
cookie = newValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The content fit of the placeholder
|
||||
*/
|
||||
var placeholderContentFit: ContentFit? = null
|
||||
|
||||
private var request: Request? = null
|
||||
private var sizeDeterminer = SizeDeterminer(imageViewHolder)
|
||||
|
||||
private fun endLoadingNewImageTraceBlock() = synchronized(this) {
|
||||
if (cookie < 0) {
|
||||
return@synchronized
|
||||
}
|
||||
|
||||
endAsyncTraceBlock(Trace.tag, Trace.loadNewImageBlock, cookie)
|
||||
cookie = -1
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
// The image view should always be valid. When the view is deallocated, all targets should be
|
||||
// canceled. Therefore that code shouldn't be called in that case. Instead of crashing, we
|
||||
// decided to ignore that.
|
||||
val imageView = imageViewHolder.get().ifNull {
|
||||
endLoadingNewImageTraceBlock()
|
||||
Log.w("ExpoImage", "The `ExpoImageViewWrapper` was deallocated, but the target wasn't canceled in time.")
|
||||
return
|
||||
}
|
||||
|
||||
// The thumbnail and full request are handled in the same way by Glide.
|
||||
// Here we're checking if the provided resource is the final bitmap or a thumbnail.
|
||||
val isPlaceholder = if (request is ThumbnailRequestCoordinator) {
|
||||
(request as? ThumbnailRequestCoordinator)
|
||||
?.getPrivateFullRequest()
|
||||
?.isComplete == false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
if (!isPlaceholder) {
|
||||
endLoadingNewImageTraceBlock()
|
||||
}
|
||||
|
||||
imageView.onResourceReady(this, resource, isPlaceholder)
|
||||
}
|
||||
|
||||
override fun onStart() = Unit
|
||||
|
||||
override fun onStop() = Unit
|
||||
|
||||
override fun onDestroy() = Unit
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) = Unit
|
||||
|
||||
// When loading fails, it's handled by the global listener, therefore that method can be NOOP.
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
endLoadingNewImageTraceBlock()
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) = Unit
|
||||
|
||||
override fun getSize(cb: SizeReadyCallback) {
|
||||
// If we can't resolve the image, we just return unknown size.
|
||||
// It shouldn't happen in a production application, because it means that our view was deallocated.
|
||||
if (imageViewHolder.get() == null) {
|
||||
cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
|
||||
return
|
||||
}
|
||||
|
||||
sizeDeterminer.getSize(cb)
|
||||
}
|
||||
|
||||
override fun removeCallback(cb: SizeReadyCallback) {
|
||||
sizeDeterminer.removeCallback(cb)
|
||||
}
|
||||
|
||||
override fun setRequest(request: Request?) {
|
||||
this.request = request
|
||||
}
|
||||
|
||||
override fun getRequest() = request
|
||||
|
||||
fun clear(requestManager: RequestManager) {
|
||||
sizeDeterminer.clearCallbacksAndListener()
|
||||
requestManager.clear(this)
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from the Glide codebase.
|
||||
// We modified that to receive a weak ref to our view instead of strong one.
|
||||
internal class SizeDeterminer(private val imageViewHolder: WeakReference<ExpoImageViewWrapper>) {
|
||||
private val cbs: MutableList<SizeReadyCallback> = ArrayList()
|
||||
|
||||
@Synthetic
|
||||
var waitForLayout = false
|
||||
private var layoutListener: SizeDeterminerLayoutListener? = null
|
||||
private fun notifyCbs(width: Int, height: Int) {
|
||||
// One or more callbacks may trigger the removal of one or more additional callbacks, so we
|
||||
// need a copy of the list to avoid a concurrent modification exception. One place this
|
||||
// happens is when a full request completes from the in memory cache while its thumbnail is
|
||||
// still being loaded asynchronously. See #2237.
|
||||
for (cb in ArrayList(cbs)) {
|
||||
cb.onSizeReady(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
@Synthetic
|
||||
fun checkCurrentDimens() {
|
||||
if (cbs.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val currentWidth = targetWidth
|
||||
val currentHeight = targetHeight
|
||||
if (!isViewStateAndSizeValid(currentWidth, currentHeight)) {
|
||||
return
|
||||
}
|
||||
notifyCbs(currentWidth, currentHeight)
|
||||
clearCallbacksAndListener()
|
||||
}
|
||||
|
||||
fun getSize(cb: SizeReadyCallback) {
|
||||
val view = imageViewHolder.get() ?: return
|
||||
|
||||
val currentWidth = targetWidth
|
||||
val currentHeight = targetHeight
|
||||
if (isViewStateAndSizeValid(currentWidth, currentHeight)) {
|
||||
cb.onSizeReady(currentWidth, currentHeight)
|
||||
return
|
||||
}
|
||||
|
||||
// We want to notify callbacks in the order they were added and we only expect one or two
|
||||
// callbacks to be added a time, so a List is a reasonable choice.
|
||||
if (!cbs.contains(cb)) {
|
||||
cbs.add(cb)
|
||||
}
|
||||
if (layoutListener == null) {
|
||||
val observer = view.viewTreeObserver
|
||||
layoutListener = SizeDeterminerLayoutListener(this)
|
||||
observer.addOnPreDrawListener(layoutListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback may be called anyway if it is removed by another [SizeReadyCallback] or
|
||||
* otherwise removed while we're notifying the list of callbacks.
|
||||
*
|
||||
*
|
||||
* See #2237.
|
||||
*/
|
||||
fun removeCallback(cb: SizeReadyCallback) {
|
||||
cbs.remove(cb)
|
||||
}
|
||||
|
||||
fun clearCallbacksAndListener() {
|
||||
// Keep a reference to the layout attachStateListener and remove it here
|
||||
// rather than having the observer remove itself because the observer
|
||||
// we add the attachStateListener to will be almost immediately merged into
|
||||
// another observer and will therefore never be alive. If we instead
|
||||
// keep a reference to the attachStateListener and remove it here, we get the
|
||||
// current view tree observer and should succeed.
|
||||
val observer = imageViewHolder.get()?.viewTreeObserver
|
||||
if (observer?.isAlive == true) {
|
||||
observer.removeOnPreDrawListener(layoutListener)
|
||||
}
|
||||
layoutListener = null
|
||||
cbs.clear()
|
||||
}
|
||||
|
||||
private fun isViewStateAndSizeValid(width: Int, height: Int): Boolean {
|
||||
return isDimensionValid(width) && isDimensionValid(height)
|
||||
}
|
||||
|
||||
private val targetHeight: Int
|
||||
get() {
|
||||
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
|
||||
val verticalPadding = view.paddingTop + view.paddingBottom
|
||||
val layoutParams = view.layoutParams
|
||||
val layoutParamSize = layoutParams?.height ?: PENDING_SIZE
|
||||
return getTargetDimen(view.height, layoutParamSize, verticalPadding)
|
||||
}
|
||||
private val targetWidth: Int
|
||||
get() {
|
||||
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
|
||||
val horizontalPadding = view.paddingLeft + view.paddingRight
|
||||
val layoutParams = view.layoutParams
|
||||
val layoutParamSize = layoutParams?.width ?: PENDING_SIZE
|
||||
return getTargetDimen(view.width, layoutParamSize, horizontalPadding)
|
||||
}
|
||||
|
||||
private fun getTargetDimen(viewSize: Int, paramSize: Int, paddingSize: Int): Int {
|
||||
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
|
||||
|
||||
// We consider the View state as valid if the View has non-null layout params and a non-zero
|
||||
// layout params width and height. This is imperfect. We're making an assumption that View
|
||||
// parents will obey their child's layout parameters, which isn't always the case.
|
||||
val adjustedParamSize = paramSize - paddingSize
|
||||
if (adjustedParamSize > 0) {
|
||||
return adjustedParamSize
|
||||
}
|
||||
|
||||
// Since we always prefer layout parameters with fixed sizes, even if waitForLayout is true,
|
||||
// we might as well ignore it and just return the layout parameters above if we have them.
|
||||
// Otherwise we should wait for a layout pass before checking the View's dimensions.
|
||||
if (waitForLayout && view.isLayoutRequested) {
|
||||
return PENDING_SIZE
|
||||
}
|
||||
|
||||
// We also consider the View state valid if the View has a non-zero width and height. This
|
||||
// means that the View has gone through at least one layout pass. It does not mean the Views
|
||||
// width and height are from the current layout pass. For example, if a View is re-used in
|
||||
// RecyclerView or ListView, this width/height may be from an old position. In some cases
|
||||
// the dimensions of the View at the old position may be different than the dimensions of the
|
||||
// View in the new position because the LayoutManager/ViewParent can arbitrarily decide to
|
||||
// change them. Nevertheless, in most cases this should be a reasonable choice.
|
||||
val adjustedViewSize = viewSize - paddingSize
|
||||
if (adjustedViewSize > 0) {
|
||||
return adjustedViewSize
|
||||
}
|
||||
|
||||
// Finally we consider the view valid if the layout parameter size is set to wrap_content.
|
||||
// It's difficult for Glide to figure out what to do here. Although Target.SIZE_ORIGINAL is a
|
||||
// coherent choice, it's extremely dangerous because original images may be much too large to
|
||||
// fit in memory or so large that only a couple can fit in memory, causing OOMs. If users want
|
||||
// the original image, they can always use .override(Target.SIZE_ORIGINAL). Since wrap_content
|
||||
// may never resolve to a real size unless we load something, we aim for a square whose length
|
||||
// is the largest screen size. That way we're loading something and that something has some
|
||||
// hope of being downsampled to a size that the device can support. We also log a warning that
|
||||
// tries to explain what Glide is doing and why some alternatives are preferable.
|
||||
// Since WRAP_CONTENT is sometimes used as a default layout parameter, we always wait for
|
||||
// layout to complete before using this fallback parameter (ConstraintLayout among others).
|
||||
if (!view.isLayoutRequested && paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
return getMaxDisplayLength(view.context)
|
||||
}
|
||||
|
||||
// If the layout parameters are < padding, the view size is < padding, or the layout
|
||||
// parameters are set to match_parent or wrap_content and no layout has occurred, we should
|
||||
// wait for layout and repeat.
|
||||
return PENDING_SIZE
|
||||
}
|
||||
|
||||
private fun isDimensionValid(size: Int): Boolean {
|
||||
return size > 0 || size == Target.SIZE_ORIGINAL
|
||||
}
|
||||
|
||||
private class SizeDeterminerLayoutListener(sizeDeterminer: SizeDeterminer) : ViewTreeObserver.OnPreDrawListener {
|
||||
private val sizeDeterminerRef: WeakReference<SizeDeterminer>
|
||||
|
||||
init {
|
||||
sizeDeterminerRef = WeakReference(sizeDeterminer)
|
||||
}
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
val sizeDeterminer = sizeDeterminerRef.get()
|
||||
sizeDeterminer?.checkCurrentDimens()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Some negative sizes (Target.SIZE_ORIGINAL) are valid, 0 is never valid.
|
||||
private const val PENDING_SIZE = 0
|
||||
|
||||
@VisibleForTesting
|
||||
var maxDisplayLength: Int? = null
|
||||
|
||||
// Use the maximum to avoid depending on the device's current orientation.
|
||||
@Suppress("DEPRECATION") // We have copied this code from Glide and are waiting for them to remove the deprecated APIs.
|
||||
private fun getMaxDisplayLength(context: Context): Int {
|
||||
if (maxDisplayLength == null) {
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val display = Preconditions.checkNotNull(windowManager).defaultDisplay
|
||||
val displayDimensions = Point()
|
||||
display.getSize(displayDimensions)
|
||||
maxDisplayLength = max(displayDimensions.x, displayDimensions.y)
|
||||
}
|
||||
return maxDisplayLength!!
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
|
||||
import java.util.*
|
||||
|
||||
object ResourceIdHelper {
|
||||
private val idMap = mutableMapOf<String, Int>()
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
private fun getResourceRawId(context: Context, name: String): Int {
|
||||
if (name.isEmpty()) {
|
||||
return -1
|
||||
}
|
||||
|
||||
val normalizedName = name.lowercase(Locale.ROOT).replace("-", "_")
|
||||
synchronized(this) {
|
||||
val id = idMap[normalizedName]
|
||||
if (id != null) {
|
||||
return id
|
||||
}
|
||||
|
||||
return context
|
||||
.resources
|
||||
.getIdentifier(normalizedName, "raw", context.packageName)
|
||||
.also {
|
||||
idMap[normalizedName] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getResourceUri(context: Context, name: String): Uri? {
|
||||
val drawableUri = ResourceDrawableIdHelper.getResourceDrawableUri(context, name)
|
||||
if (drawableUri != Uri.EMPTY) {
|
||||
return drawableUri
|
||||
}
|
||||
|
||||
val resId = getResourceRawId(context, name)
|
||||
return if (resId > 0) {
|
||||
Uri.Builder().scheme("res").path(resId.toString()).build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
package expo.modules.image
|
||||
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.request.Request
|
||||
import com.bumptech.glide.request.ThumbnailRequestCoordinator
|
||||
|
||||
fun ThumbnailRequestCoordinator.getPrivateFullRequest(): Request? {
|
||||
return getPrivateField("full")
|
||||
}
|
||||
|
||||
private fun <T> ThumbnailRequestCoordinator.getPrivateField(name: String): T? {
|
||||
return try {
|
||||
val field = this.javaClass.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
field.get(this) as T
|
||||
} catch (e: Throwable) {
|
||||
Log.e("ExpoImage", "Couldn't receive the `$name` field", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package expo.modules.image
|
||||
|
||||
object Trace {
|
||||
val tag = "ExpoImage"
|
||||
val loadNewImageBlock = "load new image"
|
||||
|
||||
private var lastCookieValue = 0
|
||||
fun getNextCookieValue() = synchronized(this) {
|
||||
lastCookieValue++
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package expo.modules.image
|
||||
|
||||
import com.facebook.yoga.YogaConstants
|
||||
|
||||
fun Float.ifYogaUndefinedUse(value: Float) =
|
||||
if (YogaConstants.isUndefined(this)) {
|
||||
value
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
inline fun Float.ifYogaDefinedUse(transformFun: (current: Float) -> Float) =
|
||||
if (YogaConstants.isUndefined(this)) {
|
||||
this
|
||||
} else {
|
||||
transformFun(this)
|
||||
}
|
||||
|
||||
fun makeYogaUndefinedIfNegative(value: Float) =
|
||||
if (!YogaConstants.isUndefined(value) && value < 0) YogaConstants.UNDEFINED else value
|
||||
Generated
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import expo.modules.kotlin.exception.CodedException
|
||||
|
||||
class BlurhashDecodingFailure(blurHash: String?) : CodedException(
|
||||
message = "Cannot decode provided blurhash '$blurHash'"
|
||||
)
|
||||
|
||||
class BlurHashFetcher(
|
||||
private val blurHash: String?,
|
||||
private val width: Int,
|
||||
private val height: Int,
|
||||
private val punch: Float
|
||||
) : DataFetcher<Bitmap> {
|
||||
override fun cleanup() = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
|
||||
val bitmap = BlurhashDecoder.decode(blurHash, width, height, punch)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(BlurhashDecodingFailure(blurHash))
|
||||
return
|
||||
}
|
||||
callback.onDataReady(bitmap)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+180
@@ -0,0 +1,180 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
/**
|
||||
* Copied from https://github.com/woltapp/blurhash.
|
||||
*/
|
||||
object BlurhashDecoder {
|
||||
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache.
|
||||
* The cache is not big, but will increase when many image sizes are used,
|
||||
* if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.clear()
|
||||
cacheCosinesY.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a blur hash into a new bitmap.
|
||||
*
|
||||
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||
val maxAc = (maxAcEnc + 1) / 166f
|
||||
val colors = Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
val colorEnc = decode83(blurHash, 2, 6)
|
||||
decodeDc(colorEnc)
|
||||
} else {
|
||||
val from = 4 + i * 2
|
||||
val colorEnc = decode83(blurHash, from, from + 2)
|
||||
decodeAc(colorEnc, maxAc * punch)
|
||||
}
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
val index = charMap[str[i]] ?: -1
|
||||
if (index != -1) {
|
||||
result = result * 83 + index
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
val b = colorEnc and 255
|
||||
return floatArrayOf(BlurhashHelpers.srgbToLinear(r), BlurhashHelpers.srgbToLinear(g), BlurhashHelpers.srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||
signedPow2((b - 9) / 9.0f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||
|
||||
private fun composeBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
numCompX: Int,
|
||||
numCompY: Int,
|
||||
colors: Array<FloatArray>,
|
||||
useCache: Boolean
|
||||
): Bitmap {
|
||||
// use an array for better performance when writing pixel colors
|
||||
val imageArray = IntArray(width * height)
|
||||
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numCompY) {
|
||||
for (i in 0 until numCompX) {
|
||||
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||
val basis = (cosX * cosY).toFloat()
|
||||
val color = colors[j * numCompX + i]
|
||||
r += color[0] * basis
|
||||
g += color[1] * basis
|
||||
b += color[2] * basis
|
||||
}
|
||||
}
|
||||
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY).also {
|
||||
cacheCosinesY[height * numCompY] = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX).also {
|
||||
cacheCosinesX[width * numCompX] = it
|
||||
}
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
|
||||
private fun DoubleArray.getCos(
|
||||
calculate: Boolean,
|
||||
x: Int,
|
||||
numComp: Int,
|
||||
y: Int,
|
||||
size: Int
|
||||
): Double {
|
||||
if (calculate) {
|
||||
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||
}
|
||||
return this[x + numComp * y]
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val charMap = listOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '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', '#', '$', '%', '*', '+', ',',
|
||||
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||
)
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
}
|
||||
Generated
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* Rewritten in kotlin from https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashEncode.swift
|
||||
*/
|
||||
object BlurhashEncoder {
|
||||
fun encode(image: Bitmap, numberOfComponents: Pair<Int, Int>): String {
|
||||
val pixels = IntArray(image.width * image.height)
|
||||
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
|
||||
|
||||
val factors = calculateBlurFactors(pixels, image.width, image.height, numberOfComponents)
|
||||
|
||||
val dc = factors.first()
|
||||
val ac = factors.drop(1)
|
||||
val hashBuilder = StringBuilder()
|
||||
|
||||
encodeFlag(numberOfComponents, hashBuilder)
|
||||
val maximumValue = encodeMaximumValue(ac, hashBuilder)
|
||||
hashBuilder.append(encode83(encodeDC(dc), 4))
|
||||
for (factor in ac) {
|
||||
hashBuilder.append(encode83(encodeAC(factor, maximumValue), 2))
|
||||
}
|
||||
|
||||
return hashBuilder.toString()
|
||||
}
|
||||
|
||||
private fun encodeFlag(numberOfComponents: Pair<Int, Int>, hashBuilder: StringBuilder) {
|
||||
val sizeFlag = (numberOfComponents.first - 1) + (numberOfComponents.second - 1) * 9
|
||||
hashBuilder.append(encode83(sizeFlag, 1))
|
||||
}
|
||||
|
||||
private fun encodeMaximumValue(ac: List<Triple<Float, Float, Float>>, hash: StringBuilder): Float {
|
||||
val maximumValue: Float
|
||||
if (ac.isNotEmpty()) {
|
||||
val actualMaximumValue = ac.maxOf { t -> max(max(abs(t.first), abs(t.second)), abs(t.third)) }
|
||||
val quantisedMaximumValue = max(0f, min(82f, floor(actualMaximumValue * 166f - 0.5f))).toInt()
|
||||
maximumValue = (quantisedMaximumValue + 1).toFloat() / 166f
|
||||
hash.append(encode83(quantisedMaximumValue, 1))
|
||||
} else {
|
||||
maximumValue = 1f
|
||||
hash.append(encode83(0, 1))
|
||||
}
|
||||
return maximumValue
|
||||
}
|
||||
|
||||
private fun calculateBlurFactors(pixels: IntArray, width: Int, height: Int, numberOfComponents: Pair<Int, Int>): List<Triple<Float, Float, Float>> {
|
||||
val factors = mutableListOf<Triple<Float, Float, Float>>()
|
||||
for (y in 0 until numberOfComponents.second) {
|
||||
for (x in 0 until numberOfComponents.first) {
|
||||
val normalisation = if (x == 0 && y == 0) 1f else 2f
|
||||
val factor = multiplyBasisFunction(pixels, width, height, x, y, normalisation)
|
||||
factors.add(factor)
|
||||
}
|
||||
}
|
||||
return factors
|
||||
}
|
||||
|
||||
private fun encode83(value: Int, length: Int): String {
|
||||
var result = ""
|
||||
for (i in 1..length) {
|
||||
val digit = (value / 83f.pow((length - i).toFloat())) % 83f
|
||||
result += ENCODE_CHARACTERS[digit.toInt()]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private const val ENCODE_CHARACTERS =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
private fun encodeDC(value: Triple<Float, Float, Float>): Int {
|
||||
val roundedR = BlurhashHelpers.linearTosRGB(value.first)
|
||||
val roundedG = BlurhashHelpers.linearTosRGB(value.second)
|
||||
val roundedB = BlurhashHelpers.linearTosRGB(value.third)
|
||||
return (roundedR shl 16) + (roundedG shl 8) + roundedB
|
||||
}
|
||||
|
||||
private fun encodeAC(value: Triple<Float, Float, Float>, maximumValue: Float): Int {
|
||||
val quantR = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.first / maximumValue, 0.5f) * 9f + 9.5f)))
|
||||
val quantG = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.second / maximumValue, 0.5f) * 9f + 9.5f)))
|
||||
val quantB = max(0f, min(18f, floor(BlurhashHelpers.signPow(value.third / maximumValue, 0.5f) * 9f + 9.5f)))
|
||||
|
||||
return (quantR * 19f * 19f + quantG * 19f + quantB).toInt()
|
||||
}
|
||||
|
||||
private fun multiplyBasisFunction(pixels: IntArray, width: Int, height: Int, x: Int, y: Int, normalisation: Float): Triple<Float, Float, Float> {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
|
||||
for (j in 0 until height) {
|
||||
for (i in 0 until width) {
|
||||
val basis = normalisation * cos(PI.toFloat() * x * i / width) * cos(PI.toFloat() * y * j / height)
|
||||
|
||||
val pixel = pixels[i + j * width]
|
||||
val pr = BlurhashHelpers.srgbToLinear(Color.red(pixel))
|
||||
val pg = BlurhashHelpers.srgbToLinear(Color.green(pixel))
|
||||
val pb = BlurhashHelpers.srgbToLinear(Color.blue(pixel))
|
||||
|
||||
r += basis * pr
|
||||
g += basis * pg
|
||||
b += basis * pb
|
||||
}
|
||||
}
|
||||
|
||||
val scale = 1f / (width * height)
|
||||
return Triple(r * scale, g * scale, b * scale)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlin.math.*
|
||||
|
||||
object BlurhashHelpers {
|
||||
fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
fun linearTosRGB(value: Float): Int {
|
||||
val v = max(0f, min(1f, value))
|
||||
return if (v <= 0.0031308) {
|
||||
(v * 12.92 * 255 + 0.5).toInt()
|
||||
} else {
|
||||
(1.055 * (v.pow(1f / 2.4f) - 0.055) * 255 + 0.5).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun signPow(value: Float, exp: Float): Float {
|
||||
return abs(value).pow(exp) * sign(value)
|
||||
}
|
||||
|
||||
fun getBitsPerPixel(bitmap: Bitmap): Int {
|
||||
return when (bitmap.config) {
|
||||
Bitmap.Config.ARGB_8888 -> 32
|
||||
Bitmap.Config.RGB_565 -> 16
|
||||
Bitmap.Config.ALPHA_8 -> 8
|
||||
Bitmap.Config.ARGB_4444 -> 16
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class BlurhashModel(
|
||||
val uri: Uri,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
Generated
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
|
||||
class BlurhashModelLoader : ModelLoader<BlurhashModel, Bitmap> {
|
||||
override fun handles(model: BlurhashModel): Boolean = true
|
||||
|
||||
override fun buildLoadData(
|
||||
model: BlurhashModel,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<Bitmap> {
|
||||
val blurhash = getPath(model.uri, 0, null) { it }
|
||||
return ModelLoader.LoadData(
|
||||
ObjectKey(model),
|
||||
BlurHashFetcher(blurhash, model.width, model.height, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> getPath(uri: Uri, index: Int, default: T, converter: (String) -> T): T {
|
||||
val value = uri.pathSegments.getOrNull(index) ?: return default
|
||||
return converter(value)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
|
||||
class BlurhashModelLoaderFactory : ModelLoaderFactory<BlurhashModel, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<BlurhashModel, Bitmap> =
|
||||
BlurhashModelLoader()
|
||||
|
||||
override fun teardown() = Unit
|
||||
}
|
||||
Generated
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
package expo.modules.image.blurhash
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
|
||||
@GlideModule
|
||||
class BlurhashModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
super.registerComponents(context, glide, registry)
|
||||
registry.prepend(BlurhashModel::class.java, Bitmap::class.java, BlurhashModelLoaderFactory())
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
package expo.modules.image.dataurls
|
||||
|
||||
import android.util.Base64
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class Base64DataFetcher(private val data: String) : DataFetcher<ByteBuffer> {
|
||||
override fun cleanup() = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
|
||||
val base64Section = getBase64Section()
|
||||
val data = Base64.decode(base64Section, Base64.DEFAULT)
|
||||
val byteBuffer = ByteBuffer.wrap(data)
|
||||
callback.onDataReady(byteBuffer)
|
||||
}
|
||||
|
||||
private fun getBase64Section(): String {
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs.
|
||||
val startOfBase64Section = data.indexOf(',')
|
||||
return data.substring(startOfBase64Section + 1)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
package expo.modules.image.dataurls
|
||||
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Loads an [java.io.InputStream] from a Base 64 encoded String.
|
||||
*/
|
||||
class Base64ModelLoader : ModelLoader<String, ByteBuffer> {
|
||||
override fun handles(model: String): Boolean {
|
||||
return model.startsWith("data:")
|
||||
}
|
||||
|
||||
override fun buildLoadData(
|
||||
model: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<ByteBuffer> {
|
||||
return ModelLoader.LoadData(ObjectKey(model), Base64DataFetcher(model))
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
package expo.modules.image.dataurls
|
||||
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class Base64ModelLoaderFactory : ModelLoaderFactory<String, ByteBuffer> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, ByteBuffer> =
|
||||
Base64ModelLoader()
|
||||
override fun teardown() = Unit
|
||||
}
|
||||
Generated
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
package expo.modules.image.dataurls
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@GlideModule
|
||||
class Base64Module : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
super.registerComponents(context, glide, registry)
|
||||
registry.prepend(String::class.java, ByteBuffer::class.java, Base64ModelLoaderFactory())
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
package expo.modules.image.decodedsource
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
|
||||
class DecodedFetcher(
|
||||
private val drawable: Drawable
|
||||
) : DataFetcher<Drawable> {
|
||||
override fun cleanup() = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun getDataClass(): Class<Drawable> = Drawable::class.java
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Drawable>) {
|
||||
callback.onDataReady(drawable)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
package expo.modules.image.decodedsource
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
class DecodedModel(val drawable: Drawable)
|
||||
node_modules/expo-image/android/src/main/java/expo/modules/image/decodedsource/DecodedModelLoader.kt
Generated
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
package expo.modules.image.decodedsource
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
|
||||
class DecodedModelLoader : ModelLoader<DecodedModel, Drawable> {
|
||||
override fun handles(model: DecodedModel): Boolean = true
|
||||
override fun buildLoadData(
|
||||
model: DecodedModel,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<Drawable> {
|
||||
return ModelLoader.LoadData(ObjectKey(model), DecodedFetcher(model.drawable))
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
package expo.modules.image.decodedsource
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
|
||||
class DecodedModelLoaderFactory : ModelLoaderFactory<DecodedModel, Drawable> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<DecodedModel, Drawable> =
|
||||
DecodedModelLoader()
|
||||
|
||||
override fun teardown() = Unit
|
||||
}
|
||||
Generated
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
package expo.modules.image.decodedsource
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
|
||||
@GlideModule
|
||||
class DecodedModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
super.registerComponents(context, glide, registry)
|
||||
registry.prepend(DecodedModel::class.java, Drawable::class.java, DecodedModelLoaderFactory())
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+218
@@ -0,0 +1,218 @@
|
||||
package expo.modules.image.drawing
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Outline
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import com.facebook.react.modules.i18nmanager.I18nUtil
|
||||
import com.facebook.react.uimanager.FloatUtil
|
||||
import com.facebook.react.uimanager.PixelUtil
|
||||
import com.facebook.yoga.YogaConstants
|
||||
import expo.modules.image.ifYogaUndefinedUse
|
||||
|
||||
class OutlineProvider(private val mContext: Context) : ViewOutlineProvider() {
|
||||
enum class BorderRadiusConfig {
|
||||
ALL,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
TOP_START,
|
||||
TOP_END,
|
||||
BOTTOM_START,
|
||||
BOTTOM_END
|
||||
}
|
||||
|
||||
enum class CornerRadius {
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_RIGHT,
|
||||
BOTTOM_LEFT
|
||||
}
|
||||
|
||||
private var mLayoutDirection = View.LAYOUT_DIRECTION_LTR
|
||||
private val mBounds = RectF()
|
||||
val borderRadiiConfig = FloatArray(9) { YogaConstants.UNDEFINED }
|
||||
private val mCornerRadii = FloatArray(4)
|
||||
private var mCornerRadiiInvalidated = true
|
||||
private val mConvexPath = Path()
|
||||
private var mConvexPathInvalidated = true
|
||||
|
||||
init {
|
||||
updateCornerRadiiIfNeeded()
|
||||
}
|
||||
|
||||
private fun updateCornerRadiiIfNeeded() {
|
||||
if (!mCornerRadiiInvalidated) {
|
||||
return
|
||||
}
|
||||
|
||||
val isRTL = mLayoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||
val isRTLSwap = I18nUtil.instance.doLeftAndRightSwapInRTL(mContext)
|
||||
updateCornerRadius(
|
||||
CornerRadius.TOP_LEFT,
|
||||
BorderRadiusConfig.TOP_LEFT,
|
||||
BorderRadiusConfig.TOP_RIGHT,
|
||||
BorderRadiusConfig.TOP_START,
|
||||
BorderRadiusConfig.TOP_END,
|
||||
isRTL,
|
||||
isRTLSwap
|
||||
)
|
||||
updateCornerRadius(
|
||||
CornerRadius.TOP_RIGHT,
|
||||
BorderRadiusConfig.TOP_RIGHT,
|
||||
BorderRadiusConfig.TOP_LEFT,
|
||||
BorderRadiusConfig.TOP_END,
|
||||
BorderRadiusConfig.TOP_START,
|
||||
isRTL,
|
||||
isRTLSwap
|
||||
)
|
||||
updateCornerRadius(
|
||||
CornerRadius.BOTTOM_LEFT,
|
||||
BorderRadiusConfig.BOTTOM_LEFT,
|
||||
BorderRadiusConfig.BOTTOM_RIGHT,
|
||||
BorderRadiusConfig.BOTTOM_START,
|
||||
BorderRadiusConfig.BOTTOM_END,
|
||||
isRTL,
|
||||
isRTLSwap
|
||||
)
|
||||
updateCornerRadius(
|
||||
CornerRadius.BOTTOM_RIGHT,
|
||||
BorderRadiusConfig.BOTTOM_RIGHT,
|
||||
BorderRadiusConfig.BOTTOM_LEFT,
|
||||
BorderRadiusConfig.BOTTOM_END,
|
||||
BorderRadiusConfig.BOTTOM_START,
|
||||
isRTL,
|
||||
isRTLSwap
|
||||
)
|
||||
mCornerRadiiInvalidated = false
|
||||
mConvexPathInvalidated = true
|
||||
}
|
||||
|
||||
private fun updateCornerRadius(
|
||||
outputPosition: CornerRadius,
|
||||
inputPosition: BorderRadiusConfig,
|
||||
oppositePosition: BorderRadiusConfig,
|
||||
startPosition: BorderRadiusConfig,
|
||||
endPosition: BorderRadiusConfig,
|
||||
isRTL: Boolean,
|
||||
isRTLSwap: Boolean
|
||||
) {
|
||||
var radius = borderRadiiConfig[inputPosition.ordinal]
|
||||
if (isRTL) {
|
||||
if (isRTLSwap) {
|
||||
radius = borderRadiiConfig[oppositePosition.ordinal]
|
||||
}
|
||||
if (YogaConstants.isUndefined(radius)) {
|
||||
radius = borderRadiiConfig[endPosition.ordinal]
|
||||
}
|
||||
} else {
|
||||
if (YogaConstants.isUndefined(radius)) {
|
||||
radius = borderRadiiConfig[startPosition.ordinal]
|
||||
}
|
||||
}
|
||||
radius = radius
|
||||
.ifYogaUndefinedUse(borderRadiiConfig[BorderRadiusConfig.ALL.ordinal])
|
||||
.ifYogaUndefinedUse(0f)
|
||||
mCornerRadii[outputPosition.ordinal] = PixelUtil.toPixelFromDIP(radius)
|
||||
}
|
||||
|
||||
private fun updateConvexPathIfNeeded() {
|
||||
if (!mConvexPathInvalidated) {
|
||||
return
|
||||
}
|
||||
mConvexPath.reset()
|
||||
mConvexPath.addRoundRect(
|
||||
mBounds,
|
||||
floatArrayOf(
|
||||
mCornerRadii[CornerRadius.TOP_LEFT.ordinal],
|
||||
mCornerRadii[CornerRadius.TOP_LEFT.ordinal],
|
||||
mCornerRadii[CornerRadius.TOP_RIGHT.ordinal],
|
||||
mCornerRadii[CornerRadius.TOP_RIGHT.ordinal],
|
||||
mCornerRadii[CornerRadius.BOTTOM_RIGHT.ordinal],
|
||||
mCornerRadii[CornerRadius.BOTTOM_RIGHT.ordinal],
|
||||
mCornerRadii[CornerRadius.BOTTOM_LEFT.ordinal],
|
||||
mCornerRadii[CornerRadius.BOTTOM_LEFT.ordinal]
|
||||
),
|
||||
Path.Direction.CW
|
||||
)
|
||||
mConvexPathInvalidated = false
|
||||
}
|
||||
|
||||
fun hasEqualCorners(): Boolean {
|
||||
updateCornerRadiiIfNeeded()
|
||||
val initialCornerRadius = mCornerRadii[0]
|
||||
return mCornerRadii.all { initialCornerRadius == it }
|
||||
}
|
||||
|
||||
fun setBorderRadius(radius: Float, position: Int): Boolean {
|
||||
if (!FloatUtil.floatsEqual(borderRadiiConfig[position], radius)) {
|
||||
borderRadiiConfig[position] = radius
|
||||
mCornerRadiiInvalidated = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun updateBoundsAndLayoutDirection(view: View) {
|
||||
// Update layout direction
|
||||
val layoutDirection = view.layoutDirection
|
||||
if (mLayoutDirection != layoutDirection) {
|
||||
mLayoutDirection = layoutDirection
|
||||
mCornerRadiiInvalidated = true
|
||||
}
|
||||
|
||||
// Update size
|
||||
val left = 0
|
||||
val top = 0
|
||||
val right = view.width
|
||||
val bottom = view.height
|
||||
if (mBounds.left != left.toFloat() ||
|
||||
mBounds.top != top.toFloat() ||
|
||||
mBounds.right != right.toFloat() ||
|
||||
mBounds.bottom != bottom.toFloat()
|
||||
) {
|
||||
mBounds[left.toFloat(), top.toFloat(), right.toFloat()] = bottom.toFloat()
|
||||
mCornerRadiiInvalidated = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
updateBoundsAndLayoutDirection(view)
|
||||
|
||||
// Calculate outline
|
||||
updateCornerRadiiIfNeeded()
|
||||
if (hasEqualCorners()) {
|
||||
val cornerRadius = mCornerRadii[0]
|
||||
if (cornerRadius > 0) {
|
||||
outline.setRoundRect(0, 0, mBounds.width().toInt(), mBounds.height().toInt(), cornerRadius)
|
||||
} else {
|
||||
outline.setRect(0, 0, mBounds.width().toInt(), mBounds.height().toInt())
|
||||
}
|
||||
} else {
|
||||
// Clipping is not supported when using a convex path, but drawing the elevation
|
||||
// shadow is. For the particular case, we fallback to canvas clipping in the view
|
||||
// which is supposed to call `clipCanvasIfNeeded` in its `draw` method.
|
||||
updateConvexPathIfNeeded()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
outline.setPath(mConvexPath)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
outline.setConvexPath(mConvexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clipCanvasIfNeeded(canvas: Canvas, view: View) {
|
||||
updateBoundsAndLayoutDirection(view)
|
||||
updateCornerRadiiIfNeeded()
|
||||
if (!hasEqualCorners()) {
|
||||
updateConvexPathIfNeeded()
|
||||
canvas.clipPath(mConvexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
package expo.modules.image.enums
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import expo.modules.kotlin.types.Enumerable
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Describes how the image should be resized to fit its container.
|
||||
* - Note: It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
|
||||
*/
|
||||
enum class ContentFit(val value: String) : Enumerable {
|
||||
/**
|
||||
* The image is scaled to maintain its aspect ratio while fitting within the container's box.
|
||||
* The entire image is made to fill the box, while preserving its aspect ratio,
|
||||
* so the image will be "letterboxed" if its aspect ratio does not match the aspect ratio of the box.
|
||||
*/
|
||||
Contain("contain"),
|
||||
|
||||
/**
|
||||
* The image is sized to maintain its aspect ratio while filling the element's entire content box.
|
||||
* If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
|
||||
*/
|
||||
Cover("cover"),
|
||||
|
||||
/**
|
||||
* The image is sized to fill the element's content box. The entire object will completely fill the box.
|
||||
* If the image's aspect ratio does not match the aspect ratio of its box, then the image will be stretched to fit.
|
||||
*/
|
||||
Fill("fill"),
|
||||
|
||||
/**
|
||||
* The image is not resized and is centered by default.
|
||||
* When specified, the exact position can be controlled with `ContentPosition`.
|
||||
*/
|
||||
None("none"),
|
||||
|
||||
/**
|
||||
* The image is sized as if `none` or `contain` were specified,
|
||||
* whichever would result in a smaller concrete image size.
|
||||
*/
|
||||
ScaleDown("scale-down");
|
||||
|
||||
internal fun toMatrix(imageRect: RectF, viewRect: RectF, sourceWidth: Int, sourceHeight: Int) = Matrix().apply {
|
||||
when (this@ContentFit) {
|
||||
Contain -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
|
||||
Cover -> {
|
||||
val imageWidth = imageRect.width()
|
||||
val imageHeight = imageRect.height()
|
||||
|
||||
val reactWidth = viewRect.width()
|
||||
val reactHeight = viewRect.height()
|
||||
|
||||
val scale = max(reactWidth / imageWidth, reactHeight / imageHeight)
|
||||
|
||||
setScale(scale, scale)
|
||||
}
|
||||
Fill -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.FILL)
|
||||
None -> {
|
||||
// we don't need to do anything
|
||||
}
|
||||
ScaleDown -> {
|
||||
// If we have information about the original size of the source, we can resize the image more drastically.
|
||||
// In certain situations, we may even permit upscaling when we anticipate the image to be reloaded without any reduction in size,
|
||||
// which will create a seamless transition between various states.
|
||||
if (sourceWidth != -1 && sourceHeight != -1) {
|
||||
val sourceRect = RectF(0f, 0f, sourceWidth.toFloat(), sourceHeight.toFloat())
|
||||
// Rather than checking if the image rectangle is within the bounds of the view rectangle, we verify the original source rectangle.
|
||||
// We know that the newly loaded image has larger dimensions than the current one, and therefore,
|
||||
// it will not be downscaled.
|
||||
if (sourceRect.width() >= viewRect.width() || sourceRect.height() >= viewRect.height()) {
|
||||
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
|
||||
}
|
||||
// If the source rectangle is larger than the view rectangle and the image rectangle has not been upscaled to match the source rectangle,
|
||||
// temporary upscaling is necessary to ensure a seamless transition.
|
||||
// It should be noted that this upscaling is applied to the downscaled version of the image,
|
||||
// not the original source image, and will be replaced by the original asset shortly thereafter.
|
||||
else {
|
||||
setRectToRect(imageRect, sourceRect, Matrix.ScaleToFit.START)
|
||||
}
|
||||
} else {
|
||||
if (imageRect.width() >= viewRect.width() || imageRect.height() >= viewRect.height()) {
|
||||
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
package expo.modules.image.enums
|
||||
|
||||
import com.bumptech.glide.load.DataSource
|
||||
|
||||
enum class ImageCacheType(private vararg val dataSources: DataSource) {
|
||||
NONE(DataSource.LOCAL, DataSource.REMOTE),
|
||||
DISK(DataSource.DATA_DISK_CACHE, DataSource.RESOURCE_DISK_CACHE),
|
||||
MEMORY(DataSource.MEMORY_CACHE);
|
||||
|
||||
companion object {
|
||||
fun fromNativeValue(value: DataSource): ImageCacheType =
|
||||
entries.firstOrNull { it.dataSources.contains(value) } ?: NONE
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
package expo.modules.image.enums
|
||||
|
||||
import expo.modules.kotlin.types.Enumerable
|
||||
|
||||
enum class Priority(val value: String) : Enumerable {
|
||||
LOW("low"),
|
||||
NORMAL("normal"),
|
||||
HIGH("high");
|
||||
|
||||
internal fun toGlidePriority(): com.bumptech.glide.Priority = when (this) {
|
||||
LOW -> com.bumptech.glide.Priority.LOW
|
||||
NORMAL -> com.bumptech.glide.Priority.NORMAL
|
||||
HIGH -> com.bumptech.glide.Priority.IMMEDIATE
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
package expo.modules.image.events
|
||||
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import expo.modules.image.ExpoImageViewWrapper
|
||||
import expo.modules.image.enums.ImageCacheType
|
||||
import expo.modules.image.records.ImageErrorEvent
|
||||
import expo.modules.image.records.ImageLoadEvent
|
||||
import expo.modules.image.records.ImageSource
|
||||
import expo.modules.image.svg.SVGPictureDrawable
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
|
||||
class GlideRequestListener(
|
||||
private val expoImageViewWrapper: WeakReference<ExpoImageViewWrapper>
|
||||
) : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
val errorMessage = e
|
||||
?.message
|
||||
// Glide always append that line to the end of the message.
|
||||
// It's not possible to call `logRootCauses` from the JS, so we decided to remove it.
|
||||
?.removeSuffix("\n call GlideException#logRootCauses(String) for more detail")
|
||||
?: "Unknown error"
|
||||
|
||||
expoImageViewWrapper
|
||||
.get()
|
||||
?.onError
|
||||
?.invoke(ImageErrorEvent(errorMessage))
|
||||
|
||||
Log.e("ExpoImage", errorMessage)
|
||||
e?.logRootCauses("ExpoImage")
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable>,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
val intrinsicWidth = (resource as? SVGPictureDrawable)?.svgIntrinsicWidth
|
||||
?: resource.intrinsicWidth
|
||||
val intrinsicHeight = (resource as? SVGPictureDrawable)?.svgIntrinsicHeight
|
||||
?: resource.intrinsicHeight
|
||||
|
||||
val imageWrapper = expoImageViewWrapper.get() ?: return false
|
||||
val appContext = imageWrapper.appContext
|
||||
appContext.mainQueue.launch {
|
||||
imageWrapper.onLoad.invoke(
|
||||
ImageLoadEvent(
|
||||
cacheType = ImageCacheType.fromNativeValue(dataSource).name.lowercase(Locale.getDefault()),
|
||||
source = ImageSource(
|
||||
url = model.toString(),
|
||||
width = intrinsicWidth,
|
||||
height = intrinsicHeight,
|
||||
mediaType = null, // TODO(@lukmccall): add mediaType
|
||||
isAnimated = resource is Animatable
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
package expo.modules.image.events
|
||||
|
||||
import com.facebook.react.modules.network.ProgressListener
|
||||
import expo.modules.image.ExpoImageViewWrapper
|
||||
import expo.modules.image.records.ImageProgressEvent
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class OkHttpProgressListener(
|
||||
private val expoImageViewWrapper: WeakReference<ExpoImageViewWrapper>
|
||||
) : ProgressListener {
|
||||
override fun onProgress(bytesWritten: Long, contentLength: Long, done: Boolean) {
|
||||
// OkHttp calls that function twice at the end - when the last byte was downloaded with done set to false,
|
||||
// and also shortly after, with done set to true. In both cases, the bytesWritten and the contentLength are equal.
|
||||
// We want to avoid sending two same events to JS, that's why we return when done is set to true.
|
||||
if (contentLength <= 0 || done) {
|
||||
return
|
||||
}
|
||||
|
||||
expoImageViewWrapper.get()?.onProgress?.invoke(
|
||||
ImageProgressEvent(
|
||||
loaded = bytesWritten.toInt(),
|
||||
total = contentLength.toInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
package expo.modules.image.okhttp
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.Headers
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import expo.modules.image.events.OkHttpProgressListener
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* GlideUrl with custom cache key.
|
||||
* It wraps the base implementation and overrides logic behind cache key and when two
|
||||
* objects are equal. Typically, Glide uses the only cache key to compare objects.
|
||||
* It won't suit our use case. We want to make custom cache key transparent and
|
||||
* not use it to compare objects.
|
||||
*/
|
||||
class GlideUrlWithCustomCacheKey(
|
||||
uri: String?,
|
||||
headers: Headers?,
|
||||
private val cacheKey: String
|
||||
) : GlideUrl(uri, headers) {
|
||||
/**
|
||||
* Cached hash code value
|
||||
*/
|
||||
private var hashCode = 0
|
||||
|
||||
/**
|
||||
* @return a super cache key from [GlideUrl]
|
||||
*/
|
||||
private fun getBaseCacheKey(): String = super.getCacheKey()
|
||||
|
||||
override fun getCacheKey(): String = cacheKey
|
||||
|
||||
// Mostly copied from GlideUrl::equal
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is GlideUrlWithCustomCacheKey) {
|
||||
return getBaseCacheKey() == other.getBaseCacheKey() && headers.equals(other.headers)
|
||||
} else if (other is GlideUrl) {
|
||||
return getBaseCacheKey() == other.cacheKey && headers.equals(other.headers)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mostly copied from GlideUrl::hashCode
|
||||
override fun hashCode(): Int {
|
||||
if (hashCode == 0) {
|
||||
hashCode = getBaseCacheKey().hashCode()
|
||||
hashCode = 31 * hashCode + headers.hashCode()
|
||||
}
|
||||
return hashCode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To connect listener with the request we have to create custom model.
|
||||
* In that way, we're passing more information to the final data loader.
|
||||
*/
|
||||
data class GlideUrlWrapper(val glideUrl: GlideUrl) {
|
||||
var progressListener: OkHttpProgressListener? = null
|
||||
|
||||
override fun toString(): String {
|
||||
return glideUrl.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@GlideModule
|
||||
class ExpoImageOkHttpClientGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
val client = OkHttpClient()
|
||||
// We don't use the `GlideUrl` directly but we want to replace the default okhttp loader anyway
|
||||
// to make sure that the app will use only one client.
|
||||
registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(client))
|
||||
registry.prepend(GlideUrlWrapper::class.java, InputStream::class.java, GlideUrlWrapperLoader.Factory(client))
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
package expo.modules.image.okhttp
|
||||
|
||||
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.facebook.react.modules.network.ProgressResponseBody
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.InputStream
|
||||
|
||||
class GlideUrlWrapperLoader(
|
||||
private val commonClient: OkHttpClient
|
||||
) : ModelLoader<GlideUrlWrapper, InputStream> {
|
||||
override fun buildLoadData(
|
||||
model: GlideUrlWrapper,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<InputStream>? {
|
||||
val loader = OkHttpUrlLoader(
|
||||
commonClient
|
||||
.newBuilder()
|
||||
.addInterceptor(
|
||||
Interceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse
|
||||
.newBuilder()
|
||||
.body(
|
||||
ProgressResponseBody(requireNotNull(originalResponse.body)) { bytesWritten, contentLength, done ->
|
||||
model.progressListener?.onProgress(bytesWritten, contentLength, done)
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
return loader.buildLoadData(model.glideUrl, width, height, options)
|
||||
}
|
||||
|
||||
// The default http loader always returns true.
|
||||
override fun handles(model: GlideUrlWrapper): Boolean = true
|
||||
|
||||
class Factory(
|
||||
private val commonClient: OkHttpClient
|
||||
) : ModelLoaderFactory<GlideUrlWrapper, InputStream> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GlideUrlWrapper, InputStream> =
|
||||
GlideUrlWrapperLoader(commonClient)
|
||||
|
||||
override fun teardown() = Unit
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import expo.modules.kotlin.types.Enumerable
|
||||
|
||||
enum class CachePolicy(val value: String) : Enumerable {
|
||||
NONE("none"),
|
||||
DISK("disk"),
|
||||
MEMORY("memory"),
|
||||
MEMORY_AND_DISK("memory-disk")
|
||||
}
|
||||
Generated
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import expo.modules.image.calcXTranslation
|
||||
import expo.modules.image.calcYTranslation
|
||||
import expo.modules.kotlin.records.Field
|
||||
import expo.modules.kotlin.records.Record
|
||||
|
||||
/**
|
||||
* Represents a position value that might be either `Double` or `String`.
|
||||
* TODO(@lukmccall): Use `Either` instead of `Any`
|
||||
*/
|
||||
typealias ContentPositionValue = Any
|
||||
|
||||
private typealias CalcAxisOffset = (
|
||||
value: Float,
|
||||
imageRect: RectF,
|
||||
viewRect: RectF,
|
||||
isPercentage: Boolean,
|
||||
isReverse: Boolean
|
||||
) -> Float
|
||||
|
||||
class ContentPosition : Record {
|
||||
@Field
|
||||
val top: ContentPositionValue? = null
|
||||
|
||||
@Field
|
||||
val bottom: ContentPositionValue? = null
|
||||
|
||||
@Field
|
||||
val right: ContentPositionValue? = null
|
||||
|
||||
@Field
|
||||
val left: ContentPositionValue? = null
|
||||
|
||||
private fun ContentPositionValue?.calcOffset(
|
||||
isReverse: Boolean,
|
||||
imageRect: RectF,
|
||||
viewRect: RectF,
|
||||
calcAxisOffset: CalcAxisOffset
|
||||
): Float? {
|
||||
if (this == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return if (this is Double) {
|
||||
val value = this.toFloat()
|
||||
calcAxisOffset(value, imageRect, viewRect, false, isReverse)
|
||||
} else {
|
||||
val value = this as String
|
||||
|
||||
if (value == "center") {
|
||||
calcAxisOffset(50f, imageRect, viewRect, true, isReverse)
|
||||
} else {
|
||||
calcAxisOffset(value.removeSuffix("%").toFloat(), imageRect, viewRect, true, isReverse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun offsetX(
|
||||
imageRect: RectF,
|
||||
viewRect: RectF
|
||||
): Float {
|
||||
return left.calcOffset(false, imageRect, viewRect, ::calcXTranslation)
|
||||
?: right.calcOffset(true, imageRect, viewRect, ::calcXTranslation)
|
||||
?: calcXTranslation(50f, imageRect, viewRect, isPercentage = true) // default value
|
||||
}
|
||||
|
||||
private fun offsetY(
|
||||
imageRect: RectF,
|
||||
viewRect: RectF
|
||||
): Float {
|
||||
return top.calcOffset(false, imageRect, viewRect, ::calcYTranslation)
|
||||
?: bottom.calcOffset(true, imageRect, viewRect, ::calcYTranslation)
|
||||
?: calcYTranslation(50f, imageRect, viewRect, isPercentage = true) // default value
|
||||
}
|
||||
|
||||
internal fun apply(to: Matrix, imageRect: RectF, viewRect: RectF) {
|
||||
val xOffset = offsetX(imageRect, viewRect)
|
||||
val yOffset = offsetY(imageRect, viewRect)
|
||||
|
||||
to.postTranslate(xOffset, yOffset)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val center = ContentPosition()
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import expo.modules.kotlin.types.Enumerable
|
||||
|
||||
enum class DecodeFormat(val value: String) : Enumerable {
|
||||
ARGB_8888("argb"),
|
||||
RGB_565("rgb");
|
||||
|
||||
fun toGlideFormat(): com.bumptech.glide.load.DecodeFormat {
|
||||
return when (this) {
|
||||
ARGB_8888 -> com.bumptech.glide.load.DecodeFormat.PREFER_ARGB_8888
|
||||
RGB_565 -> com.bumptech.glide.load.DecodeFormat.PREFER_RGB_565
|
||||
}
|
||||
}
|
||||
|
||||
fun toBytes(): Int {
|
||||
return when (this) {
|
||||
ARGB_8888 -> 4
|
||||
RGB_565 -> 2
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
|
||||
import expo.modules.kotlin.records.Field
|
||||
import expo.modules.kotlin.records.Record
|
||||
|
||||
data class ImageLoadOptions(
|
||||
@Field
|
||||
val maxWidth: Int = SIZE_ORIGINAL,
|
||||
@Field
|
||||
val maxHeight: Int = SIZE_ORIGINAL
|
||||
) : Record
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import expo.modules.kotlin.records.Field
|
||||
import expo.modules.kotlin.records.Record
|
||||
|
||||
data class ImageTransition(
|
||||
@Field val duration: Int = 0
|
||||
) : Record
|
||||
Generated
Vendored
+192
@@ -0,0 +1,192 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.Headers
|
||||
import com.bumptech.glide.load.model.LazyHeaders
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ApplicationVersionSignature
|
||||
import expo.modules.image.BlurhashModelProvider
|
||||
import expo.modules.image.DecodedModelProvider
|
||||
import expo.modules.image.GlideModelProvider
|
||||
import expo.modules.image.RawModelProvider
|
||||
import expo.modules.image.ThumbhashModelProvider
|
||||
import expo.modules.image.UriModelProvider
|
||||
import expo.modules.image.UrlModelProvider
|
||||
import expo.modules.image.ResourceIdHelper
|
||||
import expo.modules.image.customize
|
||||
import expo.modules.image.okhttp.GlideUrlWithCustomCacheKey
|
||||
import expo.modules.kotlin.records.Field
|
||||
import expo.modules.kotlin.records.Record
|
||||
|
||||
sealed interface Source {
|
||||
val width: Int
|
||||
val height: Int
|
||||
val scale: Double
|
||||
|
||||
val pixelCount: Double
|
||||
get() = width * height * scale * scale
|
||||
|
||||
fun createGlideModelProvider(context: Context): GlideModelProvider?
|
||||
fun createGlideOptions(context: Context): RequestOptions
|
||||
|
||||
/**
|
||||
* Whether it should use placeholder content fit when used as a placeholder
|
||||
*/
|
||||
fun usesPlaceholderContentFit(): Boolean = true
|
||||
}
|
||||
|
||||
class DecodedSource(
|
||||
val drawable: Drawable
|
||||
) : Source {
|
||||
override fun createGlideModelProvider(context: Context): GlideModelProvider {
|
||||
return DecodedModelProvider(drawable)
|
||||
}
|
||||
|
||||
override val width: Int = drawable.intrinsicWidth
|
||||
override val height: Int = drawable.intrinsicHeight
|
||||
override val scale: Double = 1.0
|
||||
|
||||
override fun createGlideOptions(context: Context): RequestOptions {
|
||||
// We don't want to cache already decoded images.
|
||||
return RequestOptions()
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
data class SourceMap(
|
||||
@Field val uri: String? = null,
|
||||
@Field override val width: Int = 0,
|
||||
@Field override val height: Int = 0,
|
||||
@Field override val scale: Double = 1.0,
|
||||
@Field val headers: Map<String, String>? = null,
|
||||
@Field val cacheKey: String? = null
|
||||
) : Source, Record {
|
||||
private var parsedUri: Uri? = null
|
||||
|
||||
private fun isDataUrl() = parsedUri?.scheme?.startsWith("data") ?: false
|
||||
|
||||
private fun isContentUrl() = parsedUri?.scheme?.startsWith("content") ?: false
|
||||
|
||||
private fun isResourceUri() = parsedUri?.scheme?.startsWith("android.resource") ?: false
|
||||
|
||||
private fun isLocalResourceUri() = parsedUri?.scheme?.startsWith("res") ?: false
|
||||
|
||||
private fun isLocalFileUri() = parsedUri?.scheme?.startsWith("file") ?: false
|
||||
|
||||
private fun isBlurhash() = parsedUri?.scheme?.startsWith("blurhash") ?: false
|
||||
|
||||
private fun isThumbhash() = parsedUri?.scheme?.startsWith("thumbhash") ?: false
|
||||
|
||||
override fun usesPlaceholderContentFit(): Boolean {
|
||||
return !isBlurhash() && !isThumbhash()
|
||||
}
|
||||
|
||||
private fun parseUri(context: Context) {
|
||||
if (parsedUri == null) {
|
||||
parsedUri = computeUri(context)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createGlideModelProvider(context: Context): GlideModelProvider? {
|
||||
if (uri.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
parseUri(context)
|
||||
if (isContentUrl() || isDataUrl()) {
|
||||
return RawModelProvider(uri)
|
||||
}
|
||||
|
||||
if (isBlurhash()) {
|
||||
return BlurhashModelProvider(
|
||||
parsedUri!!,
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
if (isThumbhash()) {
|
||||
return ThumbhashModelProvider(
|
||||
parsedUri!!
|
||||
)
|
||||
}
|
||||
|
||||
if (isResourceUri()) {
|
||||
return UriModelProvider(parsedUri!!)
|
||||
}
|
||||
|
||||
if (isLocalResourceUri()) {
|
||||
return UriModelProvider(
|
||||
// Convert `res:/` scheme to `android.resource://`.
|
||||
// Otherwise, glide can't understand the Uri.
|
||||
Uri.parse(parsedUri!!.toString().replace("res:/", "android.resource://" + context.packageName + "/"))
|
||||
)
|
||||
}
|
||||
|
||||
if (isLocalFileUri()) {
|
||||
return RawModelProvider(parsedUri!!.toString())
|
||||
}
|
||||
|
||||
val glideUrl = if (cacheKey == null) {
|
||||
GlideUrl(uri, getCustomHeaders())
|
||||
} else {
|
||||
GlideUrlWithCustomCacheKey(uri, getCustomHeaders(), cacheKey)
|
||||
}
|
||||
return UrlModelProvider(glideUrl)
|
||||
}
|
||||
|
||||
override fun createGlideOptions(context: Context): RequestOptions {
|
||||
parseUri(context)
|
||||
return RequestOptions().customize(`when` = width != 0 && height != 0) {
|
||||
// Override the size for local assets (apart from SVGs). This ensures that
|
||||
// resizeMode "center" displays the image in the correct size.
|
||||
override((width * scale).toInt(), (height * scale).toInt())
|
||||
}.customize(`when` = isResourceUri()) {
|
||||
// Every local resource (drawable) in Android has its own unique numeric id, which are
|
||||
// generated at build time. Although these ids are unique, they are not guaranteed unique
|
||||
// across builds. The underlying glide implementation caches these resources. To make
|
||||
// sure the cache does not return the wrong image, we should clear the cache when the
|
||||
// application version changes.
|
||||
apply(RequestOptions.signatureOf(ApplicationVersionSignature.obtain(context)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCustomHeaders(): Headers {
|
||||
if (headers == null) {
|
||||
return LazyHeaders.DEFAULT
|
||||
}
|
||||
|
||||
return LazyHeaders
|
||||
.Builder()
|
||||
.apply {
|
||||
headers.forEach { (key, value) ->
|
||||
addHeader(key, value)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun computeUri(context: Context): Uri? {
|
||||
val stringUri = uri ?: return null
|
||||
return try {
|
||||
val uri: Uri = Uri.parse(stringUri)
|
||||
// Verify scheme is set, so that relative uri (used by static resources) are not handled.
|
||||
if (uri.scheme == null) {
|
||||
computeLocalUri(stringUri, context)
|
||||
} else {
|
||||
uri
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
computeLocalUri(stringUri, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeLocalUri(stringUri: String, context: Context): Uri? {
|
||||
return ResourceIdHelper.getResourceUri(context, stringUri)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
package expo.modules.image.records
|
||||
|
||||
import expo.modules.kotlin.records.Field
|
||||
import expo.modules.kotlin.records.Record
|
||||
|
||||
data class ImageSource(
|
||||
@Field val url: String,
|
||||
@Field val width: Int,
|
||||
@Field val height: Int,
|
||||
@Field val mediaType: String?,
|
||||
@Field val isAnimated: Boolean
|
||||
) : Record
|
||||
|
||||
data class ImageLoadEvent(
|
||||
@Field val cacheType: String,
|
||||
@Field val source: ImageSource
|
||||
) : Record
|
||||
|
||||
data class ImageProgressEvent(
|
||||
@Field val loaded: Int,
|
||||
@Field val total: Int
|
||||
) : Record
|
||||
|
||||
data class ImageErrorEvent(
|
||||
@Field val error: String
|
||||
) : Record
|
||||
Generated
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
package expo.modules.image.svg
|
||||
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.ResourceDecoder
|
||||
import com.bumptech.glide.load.engine.Resource
|
||||
import com.bumptech.glide.load.resource.SimpleResource
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.caverock.androidsvg.SVGParseException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Decodes an SVG internal representation from an [InputStream].
|
||||
*
|
||||
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgDecoder.java
|
||||
* and rewritten to Kotlin.
|
||||
*/
|
||||
class SVGDecoder : ResourceDecoder<InputStream, SVG> {
|
||||
// TODO: Can we tell?
|
||||
override fun handles(source: InputStream, options: Options) = true
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource<SVG>? {
|
||||
return try {
|
||||
val svg: SVG = SVG.getFromInputStream(source)
|
||||
// Use document width and height if view box is not set.
|
||||
// Later, we will override the document width and height with the dimensions of the native view.
|
||||
if (svg.documentViewBox == null) {
|
||||
val documentWidth = svg.documentWidth
|
||||
val documentHeight = svg.documentHeight
|
||||
if (documentWidth != -1f && documentHeight != -1f) {
|
||||
svg.setDocumentViewBox(0f, 0f, documentWidth, documentHeight)
|
||||
}
|
||||
}
|
||||
svg.documentWidth = width.toFloat()
|
||||
svg.documentHeight = height.toFloat()
|
||||
SimpleResource(svg)
|
||||
} catch (ex: SVGParseException) {
|
||||
throw IOException("Cannot load SVG from stream", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
package expo.modules.image.svg
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Picture
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.PictureDrawable
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.engine.Resource
|
||||
import com.bumptech.glide.load.resource.SimpleResource
|
||||
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.caverock.androidsvg.applyTintColor
|
||||
import expo.modules.image.CustomOptions
|
||||
|
||||
/**
|
||||
* We have to use the intrinsicWidth/Height from the Picture to render the image at a high enough resolution, but at the same time we want to return the actual
|
||||
* preferred width and height of the SVG to JS. This class allows us to do that.
|
||||
*/
|
||||
class SVGPictureDrawable(picture: Picture, val svgIntrinsicWidth: Int, val svgIntrinsicHeight: Int) : PictureDrawable(picture)
|
||||
|
||||
/**
|
||||
* Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]).
|
||||
*
|
||||
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgDrawableTranscoder.java
|
||||
* and rewritten to Kotlin.
|
||||
*/
|
||||
class SVGDrawableTranscoder(val context: Context) : ResourceTranscoder<SVG?, Drawable> {
|
||||
override fun transcode(toTranscode: Resource<SVG?>, options: Options): Resource<Drawable> {
|
||||
val svgData = toTranscode.get()
|
||||
// If the svg doesn't have a viewBox, we can't determine its intrinsic width and height, so we default to 512x512.
|
||||
// Same dimensions are used in the AndroidSVG library when the viewBox is not set.
|
||||
val intrinsicWidth = svgData.documentViewBox?.width()?.toInt() ?: 512
|
||||
val intrinsicHeight = svgData.documentViewBox?.height()?.toInt() ?: 512
|
||||
|
||||
val tintColor = options.get(CustomOptions.tintColor)
|
||||
if (tintColor != null) {
|
||||
applyTintColor(svgData, tintColor)
|
||||
}
|
||||
|
||||
val picture = SVGPictureDrawable(
|
||||
svgData.renderToPicture(),
|
||||
intrinsicWidth,
|
||||
intrinsicHeight
|
||||
)
|
||||
return SimpleResource(
|
||||
picture
|
||||
)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
package expo.modules.image.svg
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import com.caverock.androidsvg.SVG
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* [LibraryGlideModule] registering support for SVG to Glide.
|
||||
*
|
||||
* Copied from https://github.com/bumptech/glide/blob/10acc31a16b4c1b5684f69e8de3117371dfa77a8/samples/svg/src/main/java/com/bumptech/glide/samples/svg/SvgModule.java
|
||||
* and rewritten to Kotlin.
|
||||
*/
|
||||
@GlideModule
|
||||
class SVGModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
super.registerComponents(context, glide, registry)
|
||||
registry
|
||||
.append(InputStream::class.java, SVG::class.java, SVGDecoder())
|
||||
.register(SVG::class.java, Drawable::class.java, SVGDrawableTranscoder(context))
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+222
@@ -0,0 +1,222 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
|
||||
// ThumbHash Java implementation (converted to kotlin) thanks to @evanw https://github.com/evanw/thumbhash
|
||||
object ThumbhashDecoder {
|
||||
/**
|
||||
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @return The width, height, and pixels of the rendered placeholder image.
|
||||
*/
|
||||
fun thumbHashToRGBA(hash: ByteArray): Image {
|
||||
// Read the constants
|
||||
val header24 = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
|
||||
val header16 = hash[3].toInt() and 255 or (hash[4].toInt() and 255 shl 8)
|
||||
val l_dc = (header24 and 63).toFloat() / 63.0f
|
||||
val p_dc = (header24 shr 6 and 63).toFloat() / 31.5f - 1.0f
|
||||
val q_dc = (header24 shr 12 and 63).toFloat() / 31.5f - 1.0f
|
||||
val l_scale = (header24 shr 18 and 31).toFloat() / 31.0f
|
||||
val hasAlpha = header24 shr 23 != 0
|
||||
val p_scale = (header16 shr 3 and 63).toFloat() / 63.0f
|
||||
val q_scale = (header16 shr 9 and 63).toFloat() / 63.0f
|
||||
val isLandscape = header16 shr 15 != 0
|
||||
val lx = Math.max(3, if (isLandscape) if (hasAlpha) 5 else 7 else header16 and 7)
|
||||
val ly = Math.max(3, if (isLandscape) header16 and 7 else if (hasAlpha) 5 else 7)
|
||||
val a_dc = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
|
||||
val a_scale = (hash[5].toInt() shr 4 and 15).toFloat() / 15.0f
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
val ac_start = if (hasAlpha) 6 else 5
|
||||
var ac_index = 0
|
||||
val l_channel = Channel(lx, ly)
|
||||
val p_channel = Channel(3, 3)
|
||||
val q_channel = Channel(3, 3)
|
||||
var a_channel: Channel? = null
|
||||
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale)
|
||||
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f)
|
||||
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f)
|
||||
if (hasAlpha) {
|
||||
a_channel = Channel(5, 5)
|
||||
a_channel.decode(hash, ac_start, ac_index, a_scale)
|
||||
}
|
||||
val l_ac = l_channel.ac
|
||||
val p_ac = p_channel.ac
|
||||
val q_ac = q_channel.ac
|
||||
val a_ac = if (hasAlpha) a_channel!!.ac else null
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
val ratio = thumbHashToApproximateAspectRatio(hash)
|
||||
val w = Math.round(if (ratio > 1.0f) 32.0f else 32.0f * ratio)
|
||||
val h = Math.round(if (ratio > 1.0f) 32.0f / ratio else 32.0f)
|
||||
val rgba = ByteArray(w * h * 4)
|
||||
val cx_stop = Math.max(lx, if (hasAlpha) 5 else 3)
|
||||
val cy_stop = Math.max(ly, if (hasAlpha) 5 else 3)
|
||||
val fx = FloatArray(cx_stop)
|
||||
val fy = FloatArray(cy_stop)
|
||||
var y = 0
|
||||
var i = 0
|
||||
while (y < h) {
|
||||
var x = 0
|
||||
while (x < w) {
|
||||
var l = l_dc
|
||||
var p = p_dc
|
||||
var q = q_dc
|
||||
var a = a_dc
|
||||
|
||||
// Precompute the coefficients
|
||||
for (cx in 0 until cx_stop) fx[cx] = Math.cos(Math.PI / w * (x + 0.5f) * cx).toFloat()
|
||||
for (cy in 0 until cy_stop) fy[cy] = Math.cos(Math.PI / h * (y + 0.5f) * cy).toFloat()
|
||||
|
||||
// Decode L
|
||||
run {
|
||||
var cy = 0
|
||||
var j = 0
|
||||
while (cy < ly) {
|
||||
val fy2 = fy[cy] * 2.0f
|
||||
var cx = if (cy > 0) 0 else 1
|
||||
while (cx * ly < lx * (ly - cy)) {
|
||||
l += l_ac[j] * fx[cx] * fy2
|
||||
cx++
|
||||
j++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
}
|
||||
|
||||
// Decode P and Q
|
||||
var cy = 0
|
||||
var j = 0
|
||||
while (cy < 3) {
|
||||
val fy2 = fy[cy] * 2.0f
|
||||
var cx = if (cy > 0) 0 else 1
|
||||
while (cx < 3 - cy) {
|
||||
val f = fx[cx] * fy2
|
||||
p += p_ac[j] * f
|
||||
q += q_ac[j] * f
|
||||
cx++
|
||||
j++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if (hasAlpha) {
|
||||
var cyAlpha = 0
|
||||
var k = 0
|
||||
while (cyAlpha < 5) {
|
||||
val fy2 = fy[cyAlpha] * 2.0f
|
||||
var cx = if (cyAlpha > 0) 0 else 1
|
||||
while (cx < 5 - cyAlpha) {
|
||||
a += a_ac!![k] * fx[cx] * fy2
|
||||
cx++
|
||||
k++
|
||||
}
|
||||
cyAlpha++
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to RGB
|
||||
val b = l - 2.0f / 3.0f * p
|
||||
val r = (3.0f * l - b + q) / 2.0f
|
||||
val g = r - q
|
||||
rgba[i] = Math.max(0, Math.round(255.0f * Math.min(1f, r))).toByte()
|
||||
rgba[i + 1] = Math.max(0, Math.round(255.0f * Math.min(1f, g))).toByte()
|
||||
rgba[i + 2] = Math.max(0, Math.round(255.0f * Math.min(1f, b))).toByte()
|
||||
rgba[i + 3] = Math.max(0, Math.round(255.0f * Math.min(1f, a))).toByte()
|
||||
x++
|
||||
i += 4
|
||||
}
|
||||
y++
|
||||
}
|
||||
return Image(w, h, rgba)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ThumbHash into a Bitmap image
|
||||
*/
|
||||
fun thumbHashToBitmap(hash: ByteArray): Bitmap {
|
||||
val thumbhashImage = thumbHashToRGBA(hash)
|
||||
// TODO: @behenate it should be possible to replace all of the code below with
|
||||
// with BitmapFactory.decodeByteArray but it always returns null when using thumbhashImage.rgba
|
||||
val imageArray = IntArray(thumbhashImage.width * thumbhashImage.height)
|
||||
val thumbhashImageInt = thumbhashImage.rgba.map { it.toUByte().toInt() }
|
||||
for (i in thumbhashImageInt.indices step 4) {
|
||||
imageArray[i / 4] = Color.argb(
|
||||
thumbhashImageInt[i + 3],
|
||||
thumbhashImageInt[i],
|
||||
thumbhashImageInt[i + 1],
|
||||
thumbhashImageInt[i + 2]
|
||||
)
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, thumbhashImage.width, thumbhashImage.height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @return The RGBA values for the average color. Each value ranges from 0 to 1.
|
||||
*/
|
||||
fun thumbHashToAverageRGBA(hash: ByteArray): RGBA {
|
||||
val header = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
|
||||
val l = (header and 63).toFloat() / 63.0f
|
||||
val p = (header shr 6 and 63).toFloat() / 31.5f - 1.0f
|
||||
val q = (header shr 12 and 63).toFloat() / 31.5f - 1.0f
|
||||
val hasAlpha = header shr 23 != 0
|
||||
val a = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
|
||||
val b = l - 2.0f / 3.0f * p
|
||||
val r = (3.0f * l - b + q) / 2.0f
|
||||
val g = r - q
|
||||
return RGBA(
|
||||
Math.max(0f, Math.min(1f, r)),
|
||||
Math.max(0f, Math.min(1f, g)),
|
||||
Math.max(0f, Math.min(1f, b)),
|
||||
a
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the approximate aspect ratio of the original image.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @return The approximate aspect ratio (i.e. width / height).
|
||||
*/
|
||||
fun thumbHashToApproximateAspectRatio(hash: ByteArray): Float {
|
||||
val header = hash[3]
|
||||
val hasAlpha = hash[2].toInt() and 0x80 != 0
|
||||
val isLandscape = hash[4].toInt() and 0x80 != 0
|
||||
val lx = if (isLandscape) if (hasAlpha) 5 else 7 else header.toInt() and 7
|
||||
val ly = if (isLandscape) header.toInt() and 7 else if (hasAlpha) 5 else 7
|
||||
return lx.toFloat() / ly.toFloat()
|
||||
}
|
||||
|
||||
class Image(var width: Int, var height: Int, var rgba: ByteArray)
|
||||
class RGBA(var r: Float, var g: Float, var b: Float, var a: Float)
|
||||
private class Channel(nx: Int, ny: Int) {
|
||||
var ac: FloatArray
|
||||
init {
|
||||
var n = 0
|
||||
for (cy in 0 until ny) {
|
||||
var cx = if (cy > 0) 0 else 1
|
||||
while (cx * ny < nx * (ny - cy)) {
|
||||
n++
|
||||
cx++
|
||||
}
|
||||
}
|
||||
ac = FloatArray(n)
|
||||
}
|
||||
|
||||
fun decode(hash: ByteArray, start: Int, index: Int, scale: Float): Int {
|
||||
var currentIndex = index
|
||||
for (i in ac.indices) {
|
||||
val data = hash[start + (currentIndex shr 1)].toInt() shr (currentIndex and 1 shl 2)
|
||||
ac[i] = ((data and 15).toFloat() / 7.5f - 1.0f) * scale
|
||||
currentIndex++
|
||||
}
|
||||
return currentIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+184
@@ -0,0 +1,184 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
|
||||
// ThumbHash Java implementation (converted to kotlin) thanks to @evanw https://github.com/evanw/thumbhash
|
||||
object ThumbhashEncoder {
|
||||
/**
|
||||
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
|
||||
* @param bitmap The bitmap to generate the ThumbHash from.
|
||||
* @return The ThumbHash as a byte array.
|
||||
*/
|
||||
fun encode(bitmap: Bitmap): ByteArray {
|
||||
// Encoding an image larger than 100x100 is slow with no benefit
|
||||
val resizedBitmap = resizeKeepingAspectRatio(bitmap, 100)
|
||||
val w = resizedBitmap.width
|
||||
val h = resizedBitmap.height
|
||||
|
||||
val pixels = IntArray(w * h)
|
||||
resizedBitmap.getPixels(pixels, 0, w, 0, 0, w, h)
|
||||
|
||||
var avg_r = 0f
|
||||
var avg_g = 0f
|
||||
var avg_b = 0f
|
||||
var avg_a = 0f
|
||||
var i = 0
|
||||
while (i < w * h) {
|
||||
val alpha = Color.alpha(pixels[i]) / 255.0f
|
||||
avg_r += alpha / 255.0f * Color.red(pixels[i])
|
||||
avg_g += alpha / 255.0f * Color.green(pixels[i])
|
||||
avg_b += alpha / 255.0f * Color.blue(pixels[i])
|
||||
avg_a += alpha
|
||||
i++
|
||||
}
|
||||
|
||||
if (avg_a > 0) {
|
||||
avg_r /= avg_a
|
||||
avg_g /= avg_a
|
||||
avg_b /= avg_a
|
||||
}
|
||||
val hasAlpha = avg_a < w * h
|
||||
val l_limit = if (hasAlpha) 5 else 7 // Use fewer luminance bits if there's alpha
|
||||
val lx = Math.max(1, Math.round((l_limit * w).toFloat() / Math.max(w, h).toFloat()))
|
||||
val ly = Math.max(1, Math.round((l_limit * h).toFloat() / Math.max(w, h).toFloat()))
|
||||
val l = FloatArray(w * h) // luminance
|
||||
val p = FloatArray(w * h) // yellow - blue
|
||||
val q = FloatArray(w * h) // red - green
|
||||
val a = FloatArray(w * h) // alpha
|
||||
|
||||
// Convert the image from RGBA to LPQA (composite atop the average color)
|
||||
i = 0
|
||||
while (i < w * h) {
|
||||
val alpha = (Color.alpha(pixels[i]) and 255) / 255.0f
|
||||
val r = avg_r * (1.0f - alpha) + alpha / 255.0f * Color.red(pixels[i])
|
||||
val g = avg_g * (1.0f - alpha) + alpha / 255.0f * Color.green(pixels[i])
|
||||
val b = avg_b * (1.0f - alpha) + alpha / 255.0f * Color.blue(pixels[i])
|
||||
l[i] = (r + g + b) / 3.0f
|
||||
p[i] = (r + g) / 2.0f - b
|
||||
q[i] = r - g
|
||||
a[i] = alpha
|
||||
i++
|
||||
}
|
||||
|
||||
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
|
||||
val l_channel = Channel(Math.max(3, lx), Math.max(3, ly)).encode(w, h, l)
|
||||
val p_channel = Channel(3, 3).encode(w, h, p)
|
||||
val q_channel = Channel(3, 3).encode(w, h, q)
|
||||
val a_channel = if (hasAlpha) Channel(5, 5).encode(w, h, a) else null
|
||||
|
||||
// Write the constants
|
||||
val isLandscape = w > h
|
||||
val header24 = (
|
||||
Math.round(63.0f * l_channel.dc)
|
||||
or (Math.round(31.5f + 31.5f * p_channel.dc) shl 6)
|
||||
or (Math.round(31.5f + 31.5f * q_channel.dc) shl 12)
|
||||
or (Math.round(31.0f * l_channel.scale) shl 18)
|
||||
or if (hasAlpha) 1 shl 23 else 0
|
||||
)
|
||||
val header16 = (
|
||||
(if (isLandscape) ly else lx)
|
||||
or (Math.round(63.0f * p_channel.scale) shl 3)
|
||||
or (Math.round(63.0f * q_channel.scale) shl 9)
|
||||
or if (isLandscape) 1 shl 15 else 0
|
||||
)
|
||||
val ac_start = if (hasAlpha) 6 else 5
|
||||
val ac_count = (
|
||||
l_channel.ac.size + p_channel.ac.size + q_channel.ac.size +
|
||||
if (hasAlpha) a_channel!!.ac.size else 0
|
||||
)
|
||||
val hash = ByteArray(ac_start + (ac_count + 1) / 2)
|
||||
hash[0] = header24.toByte()
|
||||
hash[1] = (header24 shr 8).toByte()
|
||||
hash[2] = (header24 shr 16).toByte()
|
||||
hash[3] = header16.toByte()
|
||||
hash[4] = (header16 shr 8).toByte()
|
||||
if (hasAlpha) {
|
||||
hash[5] = (
|
||||
Math.round(15.0f * a_channel!!.dc)
|
||||
or (Math.round(15.0f * a_channel.scale) shl 4)
|
||||
).toByte()
|
||||
}
|
||||
|
||||
// Write the varying factors
|
||||
var ac_index = 0
|
||||
ac_index = l_channel.writeTo(hash, ac_start, ac_index)
|
||||
ac_index = p_channel.writeTo(hash, ac_start, ac_index)
|
||||
ac_index = q_channel.writeTo(hash, ac_start, ac_index)
|
||||
if (hasAlpha) a_channel!!.writeTo(hash, ac_start, ac_index)
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
private fun resizeKeepingAspectRatio(bitmap: Bitmap, maxSize: Int): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val ratio = width.toFloat() / height.toFloat()
|
||||
|
||||
val newWidth: Int
|
||||
val newHeight: Int
|
||||
|
||||
if (ratio > 1) {
|
||||
newWidth = maxSize
|
||||
newHeight = (maxSize / ratio).toInt()
|
||||
} else {
|
||||
newHeight = maxSize
|
||||
newWidth = (maxSize * ratio).toInt()
|
||||
}
|
||||
|
||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||
}
|
||||
|
||||
private class Channel(var nx: Int, var ny: Int) {
|
||||
var dc = 0f
|
||||
var ac: FloatArray
|
||||
var scale = 0f
|
||||
|
||||
init {
|
||||
var n = 0
|
||||
for (cy in 0 until ny) {
|
||||
var cx = if (cy > 0) 0 else 1
|
||||
while (cx * ny < nx * (ny - cy)) {
|
||||
n++
|
||||
cx++
|
||||
}
|
||||
}
|
||||
ac = FloatArray(n)
|
||||
}
|
||||
|
||||
fun encode(w: Int, h: Int, channel: FloatArray): Channel {
|
||||
var n = 0
|
||||
val fx = FloatArray(w)
|
||||
for (cy in 0 until ny) {
|
||||
var cx = 0
|
||||
while (cx * ny < nx * (ny - cy)) {
|
||||
var f = 0f
|
||||
for (x in 0 until w) fx[x] = Math.cos(Math.PI / w * cx * (x + 0.5f)).toFloat()
|
||||
for (y in 0 until h) {
|
||||
val fy = Math.cos(Math.PI / h * cy * (y + 0.5f)).toFloat()
|
||||
for (x in 0 until w) f += channel[x + y * w] * fx[x] * fy
|
||||
}
|
||||
f /= (w * h).toFloat()
|
||||
if (cx > 0 || cy > 0) {
|
||||
ac[n++] = f
|
||||
scale = Math.max(scale, Math.abs(f))
|
||||
} else {
|
||||
dc = f
|
||||
}
|
||||
cx++
|
||||
}
|
||||
}
|
||||
if (scale > 0) for (i in ac.indices) ac[i] = 0.5f + 0.5f / scale * ac[i]
|
||||
return this
|
||||
}
|
||||
|
||||
fun writeTo(hash: ByteArray, start: Int, index: Int): Int {
|
||||
var currentIndex = index
|
||||
for (v in ac) {
|
||||
hash[start + (currentIndex shr 1)] = (hash[start + (currentIndex shr 1)].toInt() or (Math.round(15.0f * v) shl (currentIndex and 1 shl 2))).toByte()
|
||||
currentIndex++
|
||||
}
|
||||
return currentIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import expo.modules.kotlin.exception.CodedException
|
||||
|
||||
class ThumbhashDecodingFailure(thumbhash: String?, cause: Exception?) : CodedException(
|
||||
message = "Cannot decode provided thumbhash '$thumbhash' $cause"
|
||||
)
|
||||
|
||||
class ThumbhashFetcher(
|
||||
private val thumbhash: String?
|
||||
) : DataFetcher<Bitmap> {
|
||||
override fun cleanup() = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
|
||||
try {
|
||||
val decodedThumbhash = Base64.decode(thumbhash, Base64.DEFAULT)
|
||||
val bitmap = ThumbhashDecoder.thumbHashToBitmap(decodedThumbhash)
|
||||
callback.onDataReady(bitmap)
|
||||
} catch (e: Exception) {
|
||||
callback.onLoadFailed(ThumbhashDecodingFailure(thumbhash, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class ThumbhashModel(
|
||||
val uri: Uri
|
||||
)
|
||||
Generated
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
|
||||
class ThumbhashModelLoader : ModelLoader<ThumbhashModel, Bitmap> {
|
||||
|
||||
override fun handles(model: ThumbhashModel): Boolean = true
|
||||
|
||||
override fun buildLoadData(model: ThumbhashModel, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||
// The URI looks like this: thumbhash:/3OcRJYB4d3h\iIeHeEh3eIhw+j2w
|
||||
// ThumbHash may include slashes which could break the structure of the URL, so we replace them
|
||||
// with backslashes on the JS side and revert them back to slashes here, before generating the image.
|
||||
val thumbhash = model.uri.pathSegments.joinToString(separator = "/").replace("\\", "/")
|
||||
|
||||
return ModelLoader.LoadData(
|
||||
ObjectKey(model),
|
||||
ThumbhashFetcher(thumbhash)
|
||||
)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
|
||||
class ThumbhashModelLoaderFactory : ModelLoaderFactory<ThumbhashModel, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ThumbhashModel, Bitmap> =
|
||||
ThumbhashModelLoader()
|
||||
|
||||
override fun teardown() = Unit
|
||||
}
|
||||
Generated
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
package expo.modules.image.thumbhash
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
|
||||
@GlideModule
|
||||
class ThumbhashModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
super.registerComponents(context, glide, registry)
|
||||
registry.prepend(ThumbhashModel::class.java, Bitmap::class.java, ThumbhashModelLoaderFactory())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user