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.
Files changed (90) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/java/expo/modules/image/ExpoImageModule.kt +4 -0
  5. package/android/src/main/java/expo/modules/image/ExpoImageView.kt +19 -1
  6. package/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt +96 -10
  7. package/android/src/main/java/expo/modules/image/GlideModel.kt +14 -0
  8. package/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt +261 -1
  9. package/android/src/main/java/expo/modules/image/enums/ContentFit.kt +23 -3
  10. package/android/src/main/java/expo/modules/image/records/SourceMap.kt +10 -1
  11. package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashDecoder.kt +365 -0
  12. package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashFetcher.kt +31 -0
  13. package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashModelLoader.kt +23 -0
  14. package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashModelLoaderFactory.kt +14 -0
  15. package/android/src/main/java/expo/modules/image/thumbhash/ThumbhashModule.kt +17 -0
  16. package/build/ExpoImage.js +3 -5
  17. package/build/ExpoImage.js.map +1 -1
  18. package/build/ExpoImage.web.d.ts +1 -1
  19. package/build/ExpoImage.web.d.ts.map +1 -1
  20. package/build/ExpoImage.web.js +31 -24
  21. package/build/ExpoImage.web.js.map +1 -1
  22. package/build/Image.types.d.ts +27 -1
  23. package/build/Image.types.d.ts.map +1 -1
  24. package/build/Image.types.js.map +1 -1
  25. package/build/utils/AssetSourceResolver.web.d.ts +24 -0
  26. package/build/utils/AssetSourceResolver.web.d.ts.map +1 -0
  27. package/build/utils/AssetSourceResolver.web.js +67 -0
  28. package/build/utils/AssetSourceResolver.web.js.map +1 -0
  29. package/build/utils/resolveAssetSource.web.d.ts +8 -1
  30. package/build/utils/resolveAssetSource.web.d.ts.map +1 -1
  31. package/build/utils/resolveAssetSource.web.js +28 -2
  32. package/build/utils/resolveAssetSource.web.js.map +1 -1
  33. package/build/utils/resolveHashString.d.ts +14 -0
  34. package/build/utils/resolveHashString.d.ts.map +1 -0
  35. package/build/utils/resolveHashString.js +29 -0
  36. package/build/utils/resolveHashString.js.map +1 -0
  37. package/build/utils/resolveHashString.web.d.ts +20 -0
  38. package/build/utils/resolveHashString.web.d.ts.map +1 -0
  39. package/build/utils/resolveHashString.web.js +31 -0
  40. package/build/utils/resolveHashString.web.js.map +1 -0
  41. package/build/utils/resolveSources.d.ts +1 -0
  42. package/build/utils/resolveSources.d.ts.map +1 -1
  43. package/build/utils/resolveSources.js +15 -4
  44. package/build/utils/resolveSources.js.map +1 -1
  45. package/build/utils/thumbhash/thumbhash.d.ts +66 -0
  46. package/build/utils/thumbhash/thumbhash.d.ts.map +1 -0
  47. package/build/utils/thumbhash/thumbhash.js +338 -0
  48. package/build/utils/thumbhash/thumbhash.js.map +1 -0
  49. package/build/web/AnimationManager.d.ts +3 -2
  50. package/build/web/AnimationManager.d.ts.map +1 -1
  51. package/build/web/AnimationManager.js +6 -1
  52. package/build/web/AnimationManager.js.map +1 -1
  53. package/build/web/ImageWrapper.d.ts +3 -2
  54. package/build/web/ImageWrapper.d.ts.map +1 -1
  55. package/build/web/ImageWrapper.js +13 -7
  56. package/build/web/ImageWrapper.js.map +1 -1
  57. package/build/web/style.d.ts.map +1 -1
  58. package/build/web/style.js +5 -3
  59. package/build/web/style.js.map +1 -1
  60. package/ios/ImageModule.swift +1 -0
  61. package/ios/ImageSource.swift +4 -0
  62. package/ios/ImageUtils.swift +1 -1
  63. package/ios/ImageView.swift +3 -3
  64. package/ios/Thumbhash.swift +550 -0
  65. package/ios/ThumbhashLoader.swift +52 -0
  66. package/package.json +2 -2
  67. package/src/ExpoImage.tsx +3 -3
  68. package/src/ExpoImage.web.tsx +65 -53
  69. package/src/Image.types.ts +30 -1
  70. package/src/utils/AssetSourceResolver.web.ts +88 -0
  71. package/src/utils/resolveAssetSource.web.ts +40 -0
  72. package/src/utils/resolveHashString.tsx +34 -0
  73. package/src/utils/resolveHashString.web.tsx +33 -0
  74. package/src/utils/resolveSources.tsx +15 -4
  75. package/src/utils/thumbhash/thumbhash.ts +387 -0
  76. package/src/web/AnimationManager.tsx +9 -1
  77. package/src/web/ImageWrapper.tsx +24 -9
  78. package/src/web/style.tsx +5 -3
  79. package/android/src/main/java/expo/modules/image/enums/ImageResizeMode.kt +0 -20
  80. package/build/utils/resolveBlurhashString.d.ts +0 -3
  81. package/build/utils/resolveBlurhashString.d.ts.map +0 -1
  82. package/build/utils/resolveBlurhashString.js +0 -13
  83. package/build/utils/resolveBlurhashString.js.map +0 -1
  84. package/build/utils/resolveBlurhashString.web.d.ts +0 -3
  85. package/build/utils/resolveBlurhashString.web.d.ts.map +0 -1
  86. package/build/utils/resolveBlurhashString.web.js +0 -9
  87. package/build/utils/resolveBlurhashString.web.js.map +0 -1
  88. package/src/utils/resolveAssetSource.web.tsx +0 -4
  89. package/src/utils/resolveBlurhashString.tsx +0 -15
  90. 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 [blurhash](https://blurha.sh), a compact representation of a placeholder for an image
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
@@ -49,7 +49,7 @@ android {
49
49
  minSdkVersion safeExtGet("minSdkVersion", 21)
50
50
  targetSdkVersion safeExtGet("targetSdkVersion", 33)
51
51
  versionCode 1
52
- versionName "1.0.0"
52
+ versionName "1.2.0"
53
53
  }
54
54
  lintOptions {
55
55
  abortOnError false
@@ -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(imageRect, viewRect)
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
- private val requestManager = getOrCreateRequestManager(appContext, activity)
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 onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
387
- super.onLayout(changed, left, top, right, bottom)
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
- if (sourceToLoad != loadedSource || shouldRerender || (sourceToLoad == null && placeholder != null)) {
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(DownsampleStrategy.NONE)
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
- ) : com.bumptech.glide.request.target.CustomTarget<Drawable>(SIZE_ORIGINAL, SIZE_ORIGINAL) {
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
- if (imageRect.width() >= viewRect.width() || imageRect.height() >= viewRect.height()) {
64
- setRectToRect(imageRect, viewRect, Matrix.ScaleToFit.START)
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
  }