expo-image 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/expo/modules/image/ExpoImageModule.kt +4 -0
- package/android/src/main/java/expo/modules/image/ExpoImageView.kt +19 -1
- package/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt +96 -10
- package/android/src/main/java/expo/modules/image/GlideModel.kt +14 -0
- package/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt +261 -1
- package/android/src/main/java/expo/modules/image/enums/ContentFit.kt +23 -3
- package/android/src/main/java/expo/modules/image/records/SourceMap.kt +10 -1
- package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashDecoder.kt +365 -0
- package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashFetcher.kt +31 -0
- package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashModelLoader.kt +23 -0
- package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashModelLoaderFactory.kt +14 -0
- package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashModule.kt +17 -0
- package/build/ExpoImage.js +3 -5
- package/build/ExpoImage.js.map +1 -1
- package/build/ExpoImage.web.d.ts +1 -1
- package/build/ExpoImage.web.d.ts.map +1 -1
- package/build/ExpoImage.web.js +31 -24
- package/build/ExpoImage.web.js.map +1 -1
- package/build/Image.types.d.ts +27 -1
- package/build/Image.types.d.ts.map +1 -1
- package/build/Image.types.js.map +1 -1
- package/build/utils/AssetSourceResolver.web.d.ts +24 -0
- package/build/utils/AssetSourceResolver.web.d.ts.map +1 -0
- package/build/utils/AssetSourceResolver.web.js +67 -0
- package/build/utils/AssetSourceResolver.web.js.map +1 -0
- package/build/utils/resolveAssetSource.web.d.ts +8 -1
- package/build/utils/resolveAssetSource.web.d.ts.map +1 -1
- package/build/utils/resolveAssetSource.web.js +28 -2
- package/build/utils/resolveAssetSource.web.js.map +1 -1
- package/build/utils/resolveHashString.d.ts +14 -0
- package/build/utils/resolveHashString.d.ts.map +1 -0
- package/build/utils/resolveHashString.js +29 -0
- package/build/utils/resolveHashString.js.map +1 -0
- package/build/utils/resolveHashString.web.d.ts +20 -0
- package/build/utils/resolveHashString.web.d.ts.map +1 -0
- package/build/utils/resolveHashString.web.js +31 -0
- package/build/utils/resolveHashString.web.js.map +1 -0
- package/build/utils/resolveSources.d.ts +1 -0
- package/build/utils/resolveSources.d.ts.map +1 -1
- package/build/utils/resolveSources.js +15 -4
- package/build/utils/resolveSources.js.map +1 -1
- package/build/utils/thumbhash/thumbhash.d.ts +66 -0
- package/build/utils/thumbhash/thumbhash.d.ts.map +1 -0
- package/build/utils/thumbhash/thumbhash.js +338 -0
- package/build/utils/thumbhash/thumbhash.js.map +1 -0
- package/build/web/AnimationManager.d.ts +3 -2
- package/build/web/AnimationManager.d.ts.map +1 -1
- package/build/web/AnimationManager.js +6 -1
- package/build/web/AnimationManager.js.map +1 -1
- package/build/web/ImageWrapper.d.ts +3 -2
- package/build/web/ImageWrapper.d.ts.map +1 -1
- package/build/web/ImageWrapper.js +13 -7
- package/build/web/ImageWrapper.js.map +1 -1
- package/build/web/style.d.ts.map +1 -1
- package/build/web/style.js +5 -3
- package/build/web/style.js.map +1 -1
- package/ios/ImageModule.swift +1 -0
- package/ios/ImageSource.swift +4 -0
- package/ios/ImageUtils.swift +1 -1
- package/ios/ImageView.swift +3 -3
- package/ios/Thumbhash.swift +550 -0
- package/ios/ThumbhashLoader.swift +52 -0
- package/package.json +2 -2
- package/src/ExpoImage.tsx +3 -3
- package/src/ExpoImage.web.tsx +65 -53
- package/src/Image.types.ts +30 -1
- package/src/utils/AssetSourceResolver.web.ts +88 -0
- package/src/utils/resolveAssetSource.web.ts +40 -0
- package/src/utils/resolveHashString.tsx +34 -0
- package/src/utils/resolveHashString.web.tsx +33 -0
- package/src/utils/resolveSources.tsx +15 -4
- package/src/utils/thumbhash/thumbhash.ts +387 -0
- package/src/web/AnimationManager.tsx +9 -1
- package/src/web/ImageWrapper.tsx +24 -9
- package/src/web/style.tsx +5 -3
- package/android/src/main/java/expo/modules/image/enums/ImageResizeMode.kt +0 -20
- package/build/utils/resolveBlurhashString.d.ts +0 -3
- package/build/utils/resolveBlurhashString.d.ts.map +0 -1
- package/build/utils/resolveBlurhashString.js +0 -13
- package/build/utils/resolveBlurhashString.js.map +0 -1
- package/build/utils/resolveBlurhashString.web.d.ts +0 -3
- package/build/utils/resolveBlurhashString.web.d.ts.map +0 -1
- package/build/utils/resolveBlurhashString.web.js +0 -9
- package/build/utils/resolveBlurhashString.web.js.map +0 -1
- package/src/utils/resolveAssetSource.web.tsx +0 -4
- package/src/utils/resolveBlurhashString.tsx +0 -15
- package/src/utils/resolveBlurhashString.web.tsx +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,34 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
+
## 1.2.0 — 2023-04-14
|
|
14
|
+
|
|
15
|
+
### 🎉 New features
|
|
16
|
+
|
|
17
|
+
- [Web] Add support for `require()` assets. ([#21798](https://github.com/expo/expo/pull/21798) by [@aleqsio](https://github.com/aleqsio))
|
|
18
|
+
- Add `alt` prop as an alias to `accessibilityLabel`. ([#21884](https://github.com/expo/expo/pull/21884) by [@EvanBacon](https://github.com/EvanBacon))
|
|
19
|
+
- [Web] Add `accessibilityLabel` support on web. ([#21884](https://github.com/expo/expo/pull/21884) by [@EvanBacon](https://github.com/EvanBacon))
|
|
20
|
+
- Added `ThumbHash` support for Android, iOS and Web. ([#21952](https://github.com/expo/expo/pull/21952) by [@behenate](https://github.com/behenate))
|
|
21
|
+
|
|
22
|
+
### 🐛 Bug fixes
|
|
23
|
+
|
|
24
|
+
- [Web] Prevent breaking in static rendering environments. ([#21883](https://github.com/expo/expo/pull/21883) by [@EvanBacon](https://github.com/EvanBacon))
|
|
25
|
+
- [Web] Fixed monorepo asset resolution in production for Metro web. ([#22094](https://github.com/expo/expo/pull/22094) by [@EvanBacon](https://github.com/EvanBacon))
|
|
26
|
+
|
|
27
|
+
## 1.1.0 — 2023-03-25
|
|
28
|
+
|
|
29
|
+
### 🎉 New features
|
|
30
|
+
|
|
31
|
+
- [Android] Add automatic asset downscaling to improve performance. ([#21628](https://github.com/expo/expo/pull/21628) by [@lukmccall](https://github.com/lukmccall))
|
|
32
|
+
|
|
33
|
+
### 🐛 Bug fixes
|
|
34
|
+
|
|
35
|
+
- Fixed the `tintColor` not being passed to native view. ([#21576](https://github.com/expo/expo/pull/21576) by [@andrew-levy](https://github.com/andrew-levy))
|
|
36
|
+
- Fixed `canvas: trying to use a recycled bitmap` on Android. ([#21658](https://github.com/expo/expo/pull/21658) by [@lukmccall](https://github.com/lukmccall))
|
|
37
|
+
- Fixed crashes caused by empty placeholder or source on Android. ([#21695](https://github.com/expo/expo/pull/21695) by [@lukmccall](https://github.com/lukmccall))
|
|
38
|
+
- Fixes `shouldDownscale` don't respect the scale factor on iOS. ([#21839](https://github.com/expo/expo/pull/21839) by [@ouabing](https://github.com/ouabing))
|
|
39
|
+
- Fixes cache policy not being correctly applied when set to `none` on iOS. ([#21840](https://github.com/expo/expo/pull/21840) by [@ouabing](https://github.com/ouabing))
|
|
40
|
+
|
|
13
41
|
## 1.0.0 — 2023-02-21
|
|
14
42
|
|
|
15
43
|
_This version does not introduce any user-facing changes._
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ A cross-platform, performant image component for React Native and Expo.
|
|
|
14
14
|
- Designed for speed
|
|
15
15
|
- Support for many image formats (including animated ones)
|
|
16
16
|
- Disk and memory caching
|
|
17
|
-
- Supports [
|
|
17
|
+
- Supports [BlurHash](https://blurha.sh) and [ThumbHash](https://evanw.github.io/thumbhash/) - compact representations of a placeholder for an image
|
|
18
18
|
- Transitioning between images when the source changes (no more flickering!)
|
|
19
19
|
- Implements the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) and [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) properties (see [`contentFit`](#contentfit) and [`contentPosition`](#contentposition) props)
|
|
20
20
|
- Uses performant [`SDWebImage`](https://github.com/SDWebImage/SDWebImage) and [`Glide`](https://github.com/bumptech/glide) under the hood
|
package/android/build.gradle
CHANGED
|
@@ -162,6 +162,10 @@ class ExpoImageModule : Module() {
|
|
|
162
162
|
view.recyclingKey = recyclingKey
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
Prop("allowDownscaling") { view: ExpoImageViewWrapper, allowDownscaling: Boolean? ->
|
|
166
|
+
view.allowDownscaling = allowDownscaling ?: true
|
|
167
|
+
}
|
|
168
|
+
|
|
165
169
|
OnViewDidUpdateProps { view: ExpoImageViewWrapper ->
|
|
166
170
|
view.rerenderIfNeeded()
|
|
167
171
|
}
|
|
@@ -6,7 +6,9 @@ import android.graphics.Canvas
|
|
|
6
6
|
import android.graphics.Color
|
|
7
7
|
import android.graphics.PorterDuff
|
|
8
8
|
import android.graphics.RectF
|
|
9
|
+
import android.graphics.drawable.BitmapDrawable
|
|
9
10
|
import android.graphics.drawable.Drawable
|
|
11
|
+
import android.util.Log
|
|
10
12
|
import androidx.appcompat.widget.AppCompatImageView
|
|
11
13
|
import androidx.core.graphics.transform
|
|
12
14
|
import androidx.core.view.isVisible
|
|
@@ -84,7 +86,12 @@ class ExpoImageView(
|
|
|
84
86
|
val imageRect = RectF(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
|
|
85
87
|
val viewRect = RectF(0f, 0f, width.toFloat(), height.toFloat())
|
|
86
88
|
|
|
87
|
-
val matrix = contentFit.toMatrix(
|
|
89
|
+
val matrix = contentFit.toMatrix(
|
|
90
|
+
imageRect,
|
|
91
|
+
viewRect,
|
|
92
|
+
currentTarget?.sourceWidth ?: -1,
|
|
93
|
+
currentTarget?.sourceHeight ?: -1
|
|
94
|
+
)
|
|
88
95
|
val scaledImageRect = imageRect.transform(matrix)
|
|
89
96
|
|
|
90
97
|
imageMatrix = matrix.apply {
|
|
@@ -179,6 +186,17 @@ class ExpoImageView(
|
|
|
179
186
|
// is used for the Outline. Unfortunately clipping is not supported
|
|
180
187
|
// for convex-paths and we fallback to Canvas clipping.
|
|
181
188
|
outlineProvider.clipCanvasIfNeeded(canvas, this)
|
|
189
|
+
// If we encounter a recycled bitmap here, it suggests an issue where we may have failed to
|
|
190
|
+
// finish clearing the image bitmap before the UI attempts to display it.
|
|
191
|
+
// One solution could be to suppress the error and assume that the second image view is currently responsible for displaying the correct view.
|
|
192
|
+
if ((drawable as? BitmapDrawable)?.bitmap?.isRecycled == true) {
|
|
193
|
+
Log.e("ExpoImage", "Trying to use a recycled bitmap")
|
|
194
|
+
recycleView()?.let { target ->
|
|
195
|
+
(parent as? ExpoImageViewWrapper)?.requestManager?.let { requestManager ->
|
|
196
|
+
target.clear(requestManager)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
182
200
|
super.draw(canvas)
|
|
183
201
|
}
|
|
184
202
|
|
|
@@ -19,6 +19,7 @@ import com.bumptech.glide.RequestManager
|
|
|
19
19
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
20
20
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
|
21
21
|
import com.bumptech.glide.request.RequestOptions
|
|
22
|
+
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
|
|
22
23
|
import com.facebook.yoga.YogaConstants
|
|
23
24
|
import expo.modules.image.enums.ContentFit
|
|
24
25
|
import expo.modules.image.enums.Priority
|
|
@@ -37,6 +38,7 @@ import expo.modules.kotlin.AppContext
|
|
|
37
38
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
38
39
|
import expo.modules.kotlin.views.ExpoView
|
|
39
40
|
import jp.wasabeef.glide.transformations.BlurTransformation
|
|
41
|
+
import java.lang.Float.max
|
|
40
42
|
import java.lang.ref.WeakReference
|
|
41
43
|
import kotlin.math.abs
|
|
42
44
|
import kotlin.math.min
|
|
@@ -46,7 +48,7 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
46
48
|
private val activity: Activity
|
|
47
49
|
get() = appContext.currentActivity ?: throw MissingActivity()
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
internal val requestManager = getOrCreateRequestManager(appContext, activity)
|
|
50
52
|
private val progressListener = OkHttpProgressListener(WeakReference(this))
|
|
51
53
|
|
|
52
54
|
private val firstView = ExpoImageView(activity)
|
|
@@ -154,6 +156,12 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
154
156
|
field = value
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
internal var allowDownscaling: Boolean = true
|
|
160
|
+
set(value) {
|
|
161
|
+
field = value
|
|
162
|
+
shouldRerender = true
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
internal var priority: Priority = Priority.NORMAL
|
|
158
166
|
internal var cachePolicy: CachePolicy = CachePolicy.DISK
|
|
159
167
|
|
|
@@ -339,12 +347,13 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
339
347
|
|
|
340
348
|
it.isVisible = true
|
|
341
349
|
|
|
350
|
+
it.currentTarget = target
|
|
351
|
+
|
|
342
352
|
// The view isn't layout when it's invisible.
|
|
343
353
|
// Therefore, we have to set the correct size manually.
|
|
344
354
|
it.layout(0, 0, width, height)
|
|
345
355
|
|
|
346
356
|
it.applyTransformationMatrix()
|
|
347
|
-
it.currentTarget = target
|
|
348
357
|
}
|
|
349
358
|
target.isUsed = true
|
|
350
359
|
|
|
@@ -383,9 +392,13 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
383
392
|
return bestSource
|
|
384
393
|
}
|
|
385
394
|
|
|
386
|
-
override fun
|
|
387
|
-
super.
|
|
388
|
-
rerenderIfNeeded(
|
|
395
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
396
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
397
|
+
rerenderIfNeeded(
|
|
398
|
+
shouldRerenderBecauseOfResize = allowDownscaling &&
|
|
399
|
+
contentFit != ContentFit.Fill &&
|
|
400
|
+
contentFit != ContentFit.None
|
|
401
|
+
)
|
|
389
402
|
}
|
|
390
403
|
|
|
391
404
|
private fun createPropOptions(): RequestOptions {
|
|
@@ -414,7 +427,7 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
414
427
|
requestManager.clear(secondTarget)
|
|
415
428
|
}
|
|
416
429
|
|
|
417
|
-
internal fun rerenderIfNeeded() {
|
|
430
|
+
internal fun rerenderIfNeeded(shouldRerenderBecauseOfResize: Boolean = false) {
|
|
418
431
|
val bestSource = bestSource
|
|
419
432
|
val bestPlaceholder = bestPlaceholder
|
|
420
433
|
|
|
@@ -435,7 +448,8 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
435
448
|
return
|
|
436
449
|
}
|
|
437
450
|
|
|
438
|
-
|
|
451
|
+
val shouldRerender = sourceToLoad != loadedSource || shouldRerender || (sourceToLoad == null && placeholder != null)
|
|
452
|
+
if (shouldRerender || shouldRerenderBecauseOfResize) {
|
|
439
453
|
if (clearViewBeforeChangingSource) {
|
|
440
454
|
val activeView = if (firstView.drawable != null) {
|
|
441
455
|
firstView
|
|
@@ -450,7 +464,7 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
450
464
|
}
|
|
451
465
|
}
|
|
452
466
|
|
|
453
|
-
shouldRerender = false
|
|
467
|
+
this.shouldRerender = false
|
|
454
468
|
loadedSource = sourceToLoad
|
|
455
469
|
val options = bestSource?.createOptions(context)
|
|
456
470
|
val propOptions = createPropOptions()
|
|
@@ -468,13 +482,85 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
468
482
|
}
|
|
469
483
|
newTarget.hasSource = sourceToLoad != null
|
|
470
484
|
|
|
485
|
+
val downsampleStrategy = if (allowDownscaling) {
|
|
486
|
+
object : DownsampleStrategy() {
|
|
487
|
+
var wasTriggered = false
|
|
488
|
+
override fun getScaleFactor(
|
|
489
|
+
sourceWidth: Int,
|
|
490
|
+
sourceHeight: Int,
|
|
491
|
+
requestedWidth: Int,
|
|
492
|
+
requestedHeight: Int
|
|
493
|
+
): Float {
|
|
494
|
+
// The method is invoked twice per asset, but we only need to preserve the original dimensions for the first call.
|
|
495
|
+
// As Glide uses Android downsampling, it can only adjust dimensions by a factor of two,
|
|
496
|
+
// and hence two distinct scaling factors are computed to achieve greater accuracy.
|
|
497
|
+
if (!wasTriggered) {
|
|
498
|
+
newTarget.sourceWidth = sourceWidth
|
|
499
|
+
newTarget.sourceHeight = sourceHeight
|
|
500
|
+
wasTriggered = true
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// The size of the container is unknown, we don't know what to do, so we just run the default scale.
|
|
504
|
+
if (requestedWidth == SIZE_ORIGINAL || requestedHeight == SIZE_ORIGINAL) {
|
|
505
|
+
return 1f
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
val aspectRation = calculateScaleFactor(
|
|
509
|
+
sourceWidth.toFloat(),
|
|
510
|
+
sourceHeight.toFloat(),
|
|
511
|
+
requestedWidth.toFloat(),
|
|
512
|
+
requestedHeight.toFloat()
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
// We don't want to upscale the image
|
|
516
|
+
return min(1f, aspectRation)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private fun calculateScaleFactor(
|
|
520
|
+
sourceWidth: Float,
|
|
521
|
+
sourceHeight: Float,
|
|
522
|
+
requestedWidth: Float,
|
|
523
|
+
requestedHeight: Float
|
|
524
|
+
): Float = when (contentFit) {
|
|
525
|
+
ContentFit.Contain -> min(
|
|
526
|
+
requestedWidth / sourceWidth,
|
|
527
|
+
requestedHeight / sourceHeight
|
|
528
|
+
)
|
|
529
|
+
ContentFit.Cover -> max(
|
|
530
|
+
requestedWidth / sourceWidth,
|
|
531
|
+
requestedHeight / sourceHeight
|
|
532
|
+
)
|
|
533
|
+
ContentFit.Fill, ContentFit.None -> 1f
|
|
534
|
+
ContentFit.ScaleDown -> if (requestedWidth < sourceWidth || requestedHeight < sourceHeight) {
|
|
535
|
+
// The container is smaller than the image — scale it down and behave like `contain`
|
|
536
|
+
min(
|
|
537
|
+
requestedWidth / sourceWidth,
|
|
538
|
+
requestedHeight / sourceHeight
|
|
539
|
+
)
|
|
540
|
+
} else {
|
|
541
|
+
// The container is bigger than the image — don't scale it and behave like `none`
|
|
542
|
+
1f
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
override fun getSampleSizeRounding(
|
|
547
|
+
sourceWidth: Int,
|
|
548
|
+
sourceHeight: Int,
|
|
549
|
+
requestedWidth: Int,
|
|
550
|
+
requestedHeight: Int
|
|
551
|
+
) = SampleSizeRounding.QUALITY
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
DownsampleStrategy.NONE
|
|
555
|
+
}
|
|
556
|
+
|
|
471
557
|
val request = requestManager
|
|
472
558
|
.asDrawable()
|
|
473
559
|
.load(model)
|
|
474
560
|
.apply {
|
|
475
561
|
if (placeholder != null) {
|
|
476
562
|
thumbnail(requestManager.load(placeholder.glideData))
|
|
477
|
-
val newPlaceholderContentFit = if (bestPlaceholder.isBlurhash()) {
|
|
563
|
+
val newPlaceholderContentFit = if (bestPlaceholder.isBlurhash() || bestPlaceholder.isThumbhash()) {
|
|
478
564
|
contentFit
|
|
479
565
|
} else {
|
|
480
566
|
placeholderContentFit
|
|
@@ -487,7 +573,7 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
|
|
|
487
573
|
apply(it)
|
|
488
574
|
}
|
|
489
575
|
}
|
|
490
|
-
.downsample(
|
|
576
|
+
.downsample(downsampleStrategy)
|
|
491
577
|
.addListener(GlideRequestListener(WeakReference(this)))
|
|
492
578
|
.encodeQuality(100)
|
|
493
579
|
.apply(propOptions)
|
|
@@ -57,3 +57,17 @@ class GlideBlurhashModel(
|
|
|
57
57
|
return result
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
class GlideThumbhashModel(
|
|
62
|
+
var uri: Uri
|
|
63
|
+
) : GlideModel() {
|
|
64
|
+
override val glideData: GlideThumbhashModel = this
|
|
65
|
+
|
|
66
|
+
override fun equals(other: Any?): Boolean {
|
|
67
|
+
return (this === other) || other is GlideThumbhashModel && uri == other.uri
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun hashCode(): Int {
|
|
71
|
+
return uri.hashCode()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
package expo.modules.image
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Point
|
|
3
5
|
import android.graphics.drawable.Drawable
|
|
4
6
|
import android.util.Log
|
|
7
|
+
import android.view.ViewGroup
|
|
8
|
+
import android.view.ViewTreeObserver
|
|
9
|
+
import android.view.WindowManager
|
|
10
|
+
import androidx.annotation.VisibleForTesting
|
|
5
11
|
import com.bumptech.glide.RequestManager
|
|
12
|
+
import com.bumptech.glide.request.Request
|
|
6
13
|
import com.bumptech.glide.request.ThumbnailRequestCoordinator
|
|
14
|
+
import com.bumptech.glide.request.target.SizeReadyCallback
|
|
15
|
+
import com.bumptech.glide.request.target.Target
|
|
7
16
|
import com.bumptech.glide.request.transition.Transition
|
|
17
|
+
import com.bumptech.glide.util.Preconditions
|
|
18
|
+
import com.bumptech.glide.util.Synthetic
|
|
8
19
|
import expo.modules.core.utilities.ifNull
|
|
9
20
|
import expo.modules.image.enums.ContentFit
|
|
10
21
|
import java.lang.ref.WeakReference
|
|
22
|
+
import kotlin.math.max
|
|
11
23
|
|
|
12
24
|
/**
|
|
13
25
|
* A custom target to provide a smooth transition between multiple drawables.
|
|
@@ -18,11 +30,35 @@ import java.lang.ref.WeakReference
|
|
|
18
30
|
*/
|
|
19
31
|
class ImageViewWrapperTarget(
|
|
20
32
|
private val imageViewHolder: WeakReference<ExpoImageViewWrapper>,
|
|
21
|
-
) :
|
|
33
|
+
) : Target<Drawable> {
|
|
34
|
+
/**
|
|
35
|
+
* Whether the target has a main, non-placeholder source
|
|
36
|
+
*/
|
|
22
37
|
var hasSource = false
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the target is used - the asset loaded by it has been drawn in the image view
|
|
41
|
+
*/
|
|
23
42
|
var isUsed = false
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The main source height where -1 means unknown
|
|
46
|
+
*/
|
|
47
|
+
var sourceHeight = -1
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The main source width where -1 means unknown
|
|
51
|
+
*/
|
|
52
|
+
var sourceWidth = -1
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The content fit of the placeholder
|
|
56
|
+
*/
|
|
24
57
|
var placeholderContentFit: ContentFit? = null
|
|
25
58
|
|
|
59
|
+
private var request: Request? = null
|
|
60
|
+
private var sizeDeterminer = SizeDeterminer(imageViewHolder)
|
|
61
|
+
|
|
26
62
|
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
|
27
63
|
// The image view should always be valid. When the view is deallocated, all targets should be
|
|
28
64
|
// canceled. Therefore that code shouldn't be called in that case. Instead of crashing, we
|
|
@@ -45,9 +81,233 @@ class ImageViewWrapperTarget(
|
|
|
45
81
|
imageView.onResourceReady(this, resource, isPlaceholder)
|
|
46
82
|
}
|
|
47
83
|
|
|
84
|
+
override fun onStart() = Unit
|
|
85
|
+
|
|
86
|
+
override fun onStop() = Unit
|
|
87
|
+
|
|
88
|
+
override fun onDestroy() = Unit
|
|
89
|
+
|
|
90
|
+
override fun onLoadStarted(placeholder: Drawable?) = Unit
|
|
91
|
+
|
|
92
|
+
// When loading fails, it's handled by the global listener, therefore that method can be NOOP.
|
|
93
|
+
override fun onLoadFailed(errorDrawable: Drawable?) = Unit
|
|
94
|
+
|
|
48
95
|
override fun onLoadCleared(placeholder: Drawable?) = Unit
|
|
49
96
|
|
|
97
|
+
override fun getSize(cb: SizeReadyCallback) {
|
|
98
|
+
// If we can't resolve the image, we just return unknown size.
|
|
99
|
+
// It shouldn't happen in a production application, because it means that our view was deallocated.
|
|
100
|
+
if (imageViewHolder.get() == null) {
|
|
101
|
+
cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sizeDeterminer.getSize(cb)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override fun removeCallback(cb: SizeReadyCallback) {
|
|
109
|
+
sizeDeterminer.removeCallback(cb)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override fun setRequest(request: Request?) {
|
|
113
|
+
this.request = request
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override fun getRequest() = request
|
|
117
|
+
|
|
50
118
|
fun clear(requestManager: RequestManager) {
|
|
119
|
+
sizeDeterminer.clearCallbacksAndListener()
|
|
51
120
|
requestManager.clear(this)
|
|
52
121
|
}
|
|
53
122
|
}
|
|
123
|
+
|
|
124
|
+
// Copied from the Glide codebase.
|
|
125
|
+
// We modified that to receive a weak ref to our view instead of strong one.
|
|
126
|
+
internal class SizeDeterminer(private val imageViewHolder: WeakReference<ExpoImageViewWrapper>) {
|
|
127
|
+
private val cbs: MutableList<SizeReadyCallback> = ArrayList()
|
|
128
|
+
|
|
129
|
+
@Synthetic
|
|
130
|
+
var waitForLayout = false
|
|
131
|
+
private var layoutListener: SizeDeterminerLayoutListener? = null
|
|
132
|
+
private fun notifyCbs(width: Int, height: Int) {
|
|
133
|
+
// One or more callbacks may trigger the removal of one or more additional callbacks, so we
|
|
134
|
+
// need a copy of the list to avoid a concurrent modification exception. One place this
|
|
135
|
+
// happens is when a full request completes from the in memory cache while its thumbnail is
|
|
136
|
+
// still being loaded asynchronously. See #2237.
|
|
137
|
+
for (cb in ArrayList(cbs)) {
|
|
138
|
+
cb.onSizeReady(width, height)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@Synthetic
|
|
143
|
+
fun checkCurrentDimens() {
|
|
144
|
+
if (cbs.isEmpty()) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
val currentWidth = targetWidth
|
|
148
|
+
val currentHeight = targetHeight
|
|
149
|
+
if (!isViewStateAndSizeValid(currentWidth, currentHeight)) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
notifyCbs(currentWidth, currentHeight)
|
|
153
|
+
clearCallbacksAndListener()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fun getSize(cb: SizeReadyCallback) {
|
|
157
|
+
val view = imageViewHolder.get() ?: return
|
|
158
|
+
|
|
159
|
+
val currentWidth = targetWidth
|
|
160
|
+
val currentHeight = targetHeight
|
|
161
|
+
if (isViewStateAndSizeValid(currentWidth, currentHeight)) {
|
|
162
|
+
cb.onSizeReady(currentWidth, currentHeight)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// We want to notify callbacks in the order they were added and we only expect one or two
|
|
167
|
+
// callbacks to be added a time, so a List is a reasonable choice.
|
|
168
|
+
if (!cbs.contains(cb)) {
|
|
169
|
+
cbs.add(cb)
|
|
170
|
+
}
|
|
171
|
+
if (layoutListener == null) {
|
|
172
|
+
val observer = view.viewTreeObserver
|
|
173
|
+
layoutListener = SizeDeterminerLayoutListener(this)
|
|
174
|
+
observer.addOnPreDrawListener(layoutListener)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The callback may be called anyway if it is removed by another [SizeReadyCallback] or
|
|
180
|
+
* otherwise removed while we're notifying the list of callbacks.
|
|
181
|
+
*
|
|
182
|
+
*
|
|
183
|
+
* See #2237.
|
|
184
|
+
*/
|
|
185
|
+
fun removeCallback(cb: SizeReadyCallback) {
|
|
186
|
+
cbs.remove(cb)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fun clearCallbacksAndListener() {
|
|
190
|
+
// Keep a reference to the layout attachStateListener and remove it here
|
|
191
|
+
// rather than having the observer remove itself because the observer
|
|
192
|
+
// we add the attachStateListener to will be almost immediately merged into
|
|
193
|
+
// another observer and will therefore never be alive. If we instead
|
|
194
|
+
// keep a reference to the attachStateListener and remove it here, we get the
|
|
195
|
+
// current view tree observer and should succeed.
|
|
196
|
+
val observer = imageViewHolder.get()?.viewTreeObserver
|
|
197
|
+
if (observer?.isAlive == true) {
|
|
198
|
+
observer.removeOnPreDrawListener(layoutListener)
|
|
199
|
+
}
|
|
200
|
+
layoutListener = null
|
|
201
|
+
cbs.clear()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun isViewStateAndSizeValid(width: Int, height: Int): Boolean {
|
|
205
|
+
return isDimensionValid(width) && isDimensionValid(height)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private val targetHeight: Int
|
|
209
|
+
get() {
|
|
210
|
+
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
|
|
211
|
+
val verticalPadding = view.paddingTop + view.paddingBottom
|
|
212
|
+
val layoutParams = view.layoutParams
|
|
213
|
+
val layoutParamSize = layoutParams?.height ?: PENDING_SIZE
|
|
214
|
+
return getTargetDimen(view.height, layoutParamSize, verticalPadding)
|
|
215
|
+
}
|
|
216
|
+
private val targetWidth: Int
|
|
217
|
+
get() {
|
|
218
|
+
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
|
|
219
|
+
val horizontalPadding = view.paddingLeft + view.paddingRight
|
|
220
|
+
val layoutParams = view.layoutParams
|
|
221
|
+
val layoutParamSize = layoutParams?.width ?: PENDING_SIZE
|
|
222
|
+
return getTargetDimen(view.width, layoutParamSize, horizontalPadding)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fun getTargetDimen(viewSize: Int, paramSize: Int, paddingSize: Int): Int {
|
|
226
|
+
val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL
|
|
227
|
+
|
|
228
|
+
// We consider the View state as valid if the View has non-null layout params and a non-zero
|
|
229
|
+
// layout params width and height. This is imperfect. We're making an assumption that View
|
|
230
|
+
// parents will obey their child's layout parameters, which isn't always the case.
|
|
231
|
+
val adjustedParamSize = paramSize - paddingSize
|
|
232
|
+
if (adjustedParamSize > 0) {
|
|
233
|
+
return adjustedParamSize
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Since we always prefer layout parameters with fixed sizes, even if waitForLayout is true,
|
|
237
|
+
// we might as well ignore it and just return the layout parameters above if we have them.
|
|
238
|
+
// Otherwise we should wait for a layout pass before checking the View's dimensions.
|
|
239
|
+
if (waitForLayout && view.isLayoutRequested) {
|
|
240
|
+
return PENDING_SIZE
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// We also consider the View state valid if the View has a non-zero width and height. This
|
|
244
|
+
// means that the View has gone through at least one layout pass. It does not mean the Views
|
|
245
|
+
// width and height are from the current layout pass. For example, if a View is re-used in
|
|
246
|
+
// RecyclerView or ListView, this width/height may be from an old position. In some cases
|
|
247
|
+
// the dimensions of the View at the old position may be different than the dimensions of the
|
|
248
|
+
// View in the new position because the LayoutManager/ViewParent can arbitrarily decide to
|
|
249
|
+
// change them. Nevertheless, in most cases this should be a reasonable choice.
|
|
250
|
+
val adjustedViewSize = viewSize - paddingSize
|
|
251
|
+
if (adjustedViewSize > 0) {
|
|
252
|
+
return adjustedViewSize
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Finally we consider the view valid if the layout parameter size is set to wrap_content.
|
|
256
|
+
// It's difficult for Glide to figure out what to do here. Although Target.SIZE_ORIGINAL is a
|
|
257
|
+
// coherent choice, it's extremely dangerous because original images may be much too large to
|
|
258
|
+
// fit in memory or so large that only a couple can fit in memory, causing OOMs. If users want
|
|
259
|
+
// the original image, they can always use .override(Target.SIZE_ORIGINAL). Since wrap_content
|
|
260
|
+
// may never resolve to a real size unless we load something, we aim for a square whose length
|
|
261
|
+
// is the largest screen size. That way we're loading something and that something has some
|
|
262
|
+
// hope of being downsampled to a size that the device can support. We also log a warning that
|
|
263
|
+
// tries to explain what Glide is doing and why some alternatives are preferable.
|
|
264
|
+
// Since WRAP_CONTENT is sometimes used as a default layout parameter, we always wait for
|
|
265
|
+
// layout to complete before using this fallback parameter (ConstraintLayout among others).
|
|
266
|
+
if (!view.isLayoutRequested && paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
|
267
|
+
return getMaxDisplayLength(view.context)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// If the layout parameters are < padding, the view size is < padding, or the layout
|
|
271
|
+
// parameters are set to match_parent or wrap_content and no layout has occurred, we should
|
|
272
|
+
// wait for layout and repeat.
|
|
273
|
+
return PENDING_SIZE
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private fun isDimensionValid(size: Int): Boolean {
|
|
277
|
+
return size > 0 || size == Target.SIZE_ORIGINAL
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private class SizeDeterminerLayoutListener(sizeDeterminer: SizeDeterminer) : ViewTreeObserver.OnPreDrawListener {
|
|
281
|
+
private val sizeDeterminerRef: WeakReference<SizeDeterminer>
|
|
282
|
+
|
|
283
|
+
init {
|
|
284
|
+
sizeDeterminerRef = WeakReference(sizeDeterminer)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
override fun onPreDraw(): Boolean {
|
|
288
|
+
val sizeDeterminer = sizeDeterminerRef.get()
|
|
289
|
+
sizeDeterminer?.checkCurrentDimens()
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
companion object {
|
|
295
|
+
// Some negative sizes (Target.SIZE_ORIGINAL) are valid, 0 is never valid.
|
|
296
|
+
private const val PENDING_SIZE = 0
|
|
297
|
+
|
|
298
|
+
@VisibleForTesting
|
|
299
|
+
var maxDisplayLength: Int? = null
|
|
300
|
+
|
|
301
|
+
// Use the maximum to avoid depending on the device's current orientation.
|
|
302
|
+
private fun getMaxDisplayLength(context: Context): Int {
|
|
303
|
+
if (maxDisplayLength == null) {
|
|
304
|
+
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
|
305
|
+
val display = Preconditions.checkNotNull(windowManager).defaultDisplay
|
|
306
|
+
val displayDimensions = Point()
|
|
307
|
+
display.getSize(displayDimensions)
|
|
308
|
+
maxDisplayLength = max(displayDimensions.x, displayDimensions.y)
|
|
309
|
+
}
|
|
310
|
+
return maxDisplayLength!!
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -41,7 +41,7 @@ enum class ContentFit(val value: String) : Enumerable {
|
|
|
41
41
|
*/
|
|
42
42
|
ScaleDown("scale-down");
|
|
43
43
|
|
|
44
|
-
internal fun toMatrix(imageRect: RectF, viewRect: RectF) = Matrix().apply {
|
|
44
|
+
internal fun toMatrix(imageRect: RectF, viewRect: RectF, sourceWidth: Int, sourceHeight: Int) = Matrix().apply {
|
|
45
45
|
when (this@ContentFit) {
|
|
46
46
|
Contain -> setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
|
|
47
47
|
Cover -> {
|
|
@@ -60,8 +60,28 @@ enum class ContentFit(val value: String) : Enumerable {
|
|
|
60
60
|
// we don't need to do anything
|
|
61
61
|
}
|
|
62
62
|
ScaleDown -> {
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// If we have information about the original size of the source, we can resize the image more drastically.
|
|
64
|
+
// In certain situations, we may even permit upscaling when we anticipate the image to be reloaded without any reduction in size,
|
|
65
|
+
// which will create a seamless transition between various states.
|
|
66
|
+
if (sourceWidth != -1 && sourceHeight != -1) {
|
|
67
|
+
val sourceRect = RectF(0f, 0f, sourceWidth.toFloat(), sourceHeight.toFloat())
|
|
68
|
+
// Rather than checking if the image rectangle is within the bounds of the view rectangle, we verify the original source rectangle.
|
|
69
|
+
// We know that the newly loaded image has larger dimensions than the current one, and therefore,
|
|
70
|
+
// it will not be downscaled.
|
|
71
|
+
if (sourceRect.width() >= viewRect.width() || sourceRect.height() >= viewRect.height()) {
|
|
72
|
+
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
|
|
73
|
+
}
|
|
74
|
+
// If the source rectangle is larger than the view rectangle and the image rectangle has not been upscaled to match the source rectangle,
|
|
75
|
+
// temporary upscaling is necessary to ensure a seamless transition.
|
|
76
|
+
// It should be noted that this upscaling is applied to the downscaled version of the image,
|
|
77
|
+
// not the original source image, and will be replaced by the original asset shortly thereafter.
|
|
78
|
+
else {
|
|
79
|
+
setRectToRect(imageRect, sourceRect, Matrix.ScaleToFit.START)
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
if (imageRect.width() >= viewRect.width() || imageRect.height() >= viewRect.height()) {
|
|
83
|
+
setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
|
|
84
|
+
}
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
87
|
}
|