@onekeyfe/react-native-image 3.0.155 → 3.0.157

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 (38) hide show
  1. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +76 -3
  2. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +2 -0
  3. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +14 -1
  4. package/ios/OneKeyImage.swift +88 -19
  5. package/ios/OneKeyImageRequestContext.swift +34 -9
  6. package/ios/tests/OneKeyImageInfrastructureTests.swift +123 -19
  7. package/lib/module/index.js +5 -1
  8. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  9. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  10. package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  11. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  12. package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  13. package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  14. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  15. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  16. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  17. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  18. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  19. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  20. package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  21. package/lib/typescript/src/OneKeyImage.nitro.d.ts +8 -0
  22. package/lib/typescript/src/index.d.ts +1 -1
  23. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  24. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  25. package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  27. package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  28. package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  29. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  30. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  31. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  32. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  33. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  34. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  35. package/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  36. package/package.json +2 -2
  37. package/src/OneKeyImage.nitro.ts +8 -0
  38. package/src/index.tsx +5 -1
@@ -7,7 +7,9 @@ import android.content.ContextWrapper
7
7
  import android.content.res.Configuration
8
8
  import android.graphics.Canvas
9
9
  import android.graphics.Color
10
+ import android.graphics.Outline
10
11
  import android.graphics.Paint
12
+ import android.graphics.Path
11
13
  import android.graphics.drawable.Animatable
12
14
  import android.graphics.drawable.Drawable
13
15
  import android.os.Build
@@ -15,6 +17,7 @@ import android.os.Looper
15
17
  import android.os.SystemClock
16
18
  import android.provider.Settings
17
19
  import android.view.View
20
+ import android.view.ViewOutlineProvider
18
21
  import android.view.animation.DecelerateInterpolator
19
22
  import android.widget.ImageView
20
23
  import com.bumptech.glide.Glide
@@ -44,6 +47,16 @@ private fun Context.findActivity(): Activity? {
44
47
  }
45
48
 
46
49
  private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(context) {
50
+ private val roundClipPath = Path()
51
+ private val roundOutlineProvider = object : ViewOutlineProvider() {
52
+ override fun getOutline(view: View, outline: Outline) {
53
+ if (view.width > 0 && view.height > 0) {
54
+ outline.setOval(0, 0, view.width, view.height)
55
+ } else {
56
+ outline.setEmpty()
57
+ }
58
+ }
59
+ }
47
60
  private val fallbackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
48
61
  color = Color.rgb(110, 110, 120)
49
62
  textAlign = Paint.Align.CENTER
@@ -52,6 +65,16 @@ private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(conte
52
65
  var drawStateSymbol = false
53
66
  var stateSymbol = "◇"
54
67
  var autoplayEnabled = false
68
+ var round = false
69
+ set(value) {
70
+ if (field == value) return
71
+ field = value
72
+ outlineProvider = if (value) roundOutlineProvider else ViewOutlineProvider.BACKGROUND
73
+ clipToOutline = value
74
+ updateRoundClipPath()
75
+ invalidateOutline()
76
+ invalidate()
77
+ }
55
78
  private var aggregatedVisible = true
56
79
  var skeletonRequested = false
57
80
  set(value) {
@@ -65,9 +88,24 @@ private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(conte
65
88
  super.onSizeChanged(w, h, oldw, oldh)
66
89
  skeleton.updateBounds(w, h)
67
90
  fallbackPaint.textSize = minOf(w, h) * 0.35f
91
+ if (round) {
92
+ updateRoundClipPath()
93
+ invalidateOutline()
94
+ }
68
95
  if (w > 0 && h > 0) onReadyForRequest?.invoke()
69
96
  }
70
97
 
98
+ override fun draw(canvas: Canvas) {
99
+ if (!round || roundClipPath.isEmpty) {
100
+ super.draw(canvas)
101
+ return
102
+ }
103
+ val saveCount = canvas.save()
104
+ canvas.clipPath(roundClipPath)
105
+ super.draw(canvas)
106
+ canvas.restoreToCount(saveCount)
107
+ }
108
+
71
109
  override fun onDraw(canvas: Canvas) {
72
110
  super.onDraw(canvas)
73
111
  if (drawStateSymbol) {
@@ -108,6 +146,13 @@ private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(conte
108
146
  skeleton.updateBounds(width, height)
109
147
  }
110
148
 
149
+ private fun updateRoundClipPath() {
150
+ roundClipPath.reset()
151
+ if (round && width > 0 && height > 0) {
152
+ roundClipPath.addOval(0f, 0f, width.toFloat(), height.toFloat(), Path.Direction.CW)
153
+ }
154
+ }
155
+
111
156
  fun syncPlayback() {
112
157
  val canRun = isAttachedToWindow && aggregatedVisible
113
158
  if (skeletonRequested && canRun) skeleton.start() else skeleton.stop()
@@ -130,10 +175,12 @@ internal fun oneKeyImageRequestSignature(
130
175
  contentFit: OneKeyImageContentFit?,
131
176
  optimizeTos: Boolean?,
132
177
  overscan: Double?,
178
+ round: Boolean,
133
179
  width: Int,
134
180
  height: Int,
135
181
  density: Float,
136
182
  resizeWidth: Double? = null,
183
+ resizeHeight: Double? = null,
137
184
  ): String = listOf(
138
185
  rawUrl,
139
186
  sourceHeadersJson.orEmpty(),
@@ -142,7 +189,9 @@ internal fun oneKeyImageRequestSignature(
142
189
  (contentFit ?: OneKeyImageContentFit.COVER).name,
143
190
  optimizeTos.toString(),
144
191
  overscan.toString(),
192
+ round.toString(),
145
193
  resizeWidth.toString(),
194
+ resizeHeight.toString(),
146
195
  width.toString(),
147
196
  height.toString(),
148
197
  density.toString(),
@@ -241,6 +290,10 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
241
290
  field = value
242
291
  requestIdentityChanged()
243
292
  }
293
+ override var round: Boolean? = false
294
+ set(value) {
295
+ field = value
296
+ }
244
297
  override var overscan: Double? = 1.1
245
298
  set(value) {
246
299
  if (field == value) return
@@ -253,6 +306,14 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
253
306
  field = value
254
307
  requestIdentityChanged()
255
308
  }
309
+ // Layout hint pair with resizeWidth; only the iOS pre-layout memory probe
310
+ // consumes it today, Android keys the request on the measured view size.
311
+ override var resizeHeight: Double? = null
312
+ set(value) {
313
+ if (field == value) return
314
+ field = value
315
+ requestIdentityChanged()
316
+ }
256
317
  override var loadingStrategy: OneKeyImageLoadingStrategy? = OneKeyImageLoadingStrategy.STATIC
257
318
  set(value) {
258
319
  field = value
@@ -287,6 +348,10 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
287
348
 
288
349
  override fun afterUpdate() {
289
350
  super.afterUpdate()
351
+ // Fabric can reset `round` before clearing `sourceUri` while removing a view.
352
+ // Apply the shape once per committed prop batch so the outgoing frame keeps
353
+ // its clipping, while mounted and recycled views still receive the new value.
354
+ if (!sourceUri.isNullOrBlank()) hostView.round = round == true
290
355
  loadForCurrentLayout()
291
356
  }
292
357
 
@@ -346,7 +411,9 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
346
411
  autoplay = false
347
412
  recyclingKey = null
348
413
  optimizeTos = true
414
+ round = false
349
415
  resizeWidth = null
416
+ resizeHeight = null
350
417
  overscan = 1.1
351
418
  loadingStrategy = OneKeyImageLoadingStrategy.STATIC
352
419
  placeholderColor = null
@@ -416,10 +483,12 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
416
483
  contentFit = contentFit,
417
484
  optimizeTos = optimizeTos,
418
485
  overscan = overscan,
486
+ round = round == true,
419
487
  width = hostView.width,
420
488
  height = hostView.height,
421
489
  density = hostView.resources.displayMetrics.density,
422
490
  resizeWidth = resizeWidth,
491
+ resizeHeight = resizeHeight,
423
492
  )
424
493
  if (!force && signature == lastSignature) return
425
494
  lastSignature = signature
@@ -549,7 +618,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
549
618
  requestManager()
550
619
  .asDrawable()
551
620
  .load(OneKeyImageModel.build(requestUrl, headersJson))
552
- .apply(requestOptions(policy, requestUrl))
621
+ .apply(requestOptions(policy, requestUrl, round == true))
553
622
  .override(decodeDimensions.width, decodeDimensions.height)
554
623
  .listener(object : RequestListener<Drawable> {
555
624
  override fun onLoadFailed(
@@ -577,11 +646,15 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
577
646
  .into(target)
578
647
  }
579
648
 
580
- private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions {
649
+ private fun requestOptions(
650
+ policy: OneKeyImageCachePolicy,
651
+ uri: String,
652
+ round: Boolean,
653
+ ): RequestOptions {
581
654
  val options = RequestOptions()
582
655
  .withOneKeyAvatarCache(uri)
583
- .dontTransform()
584
656
  .downsample(OneKeyImageSafeDownsampleStrategy)
657
+ if (round) options.circleCrop() else options.dontTransform()
585
658
  return when (policy) {
586
659
  OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri))
587
660
  OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE)
@@ -29,6 +29,7 @@ class OneKeyImageReusableView(context: ThemedReactContext) : FrameLayout(context
29
29
  overscan: Double,
30
30
  loadingStrategy: String,
31
31
  placeholderColor: String,
32
+ round: Boolean = false,
32
33
  onLoad: (() -> Unit)? = null,
33
34
  onError: (() -> Unit)? = null,
34
35
  ) {
@@ -57,6 +58,7 @@ class OneKeyImageReusableView(context: ThemedReactContext) : FrameLayout(context
57
58
  image.autoplay = autoplay
58
59
  image.recyclingKey = recyclingKey
59
60
  image.optimizeTos = optimizeTos
61
+ image.round = round
60
62
  image.overscan = overscan
61
63
  image.loadingStrategy = when (loadingStrategy) {
62
64
  "skeleton" -> OneKeyImageLoadingStrategy.SKELETON
@@ -26,7 +26,19 @@ class OneKeyImageRequestSignatureTest {
26
26
  )
27
27
  }
28
28
 
29
- private fun signature(contentFit: OneKeyImageContentFit?, resizeWidth: Double? = null): String = oneKeyImageRequestSignature(
29
+ @Test
30
+ fun roundParticipatesInRequestIdentity() {
31
+ assertNotEquals(
32
+ signature(OneKeyImageContentFit.COVER, round = false),
33
+ signature(OneKeyImageContentFit.COVER, round = true),
34
+ )
35
+ }
36
+
37
+ private fun signature(
38
+ contentFit: OneKeyImageContentFit?,
39
+ resizeWidth: Double? = null,
40
+ round: Boolean = false,
41
+ ): String = oneKeyImageRequestSignature(
30
42
  rawUrl = "https://example.com/image.png",
31
43
  sourceHeadersJson = null,
32
44
  recyclingKey = null,
@@ -34,6 +46,7 @@ class OneKeyImageRequestSignatureTest {
34
46
  contentFit = contentFit,
35
47
  optimizeTos = true,
36
48
  overscan = 1.1,
49
+ round = round,
37
50
  width = 200,
38
51
  height = 100,
39
52
  density = 2f,
@@ -7,9 +7,13 @@ import UIKit
7
7
  private final class OneKeyImageHostView: SDAnimatedImageView {
8
8
  var onLayout: (() -> Void)?
9
9
  var onWindowChanged: ((Bool) -> Void)?
10
+ var round = false {
11
+ didSet { updateRoundMask() }
12
+ }
10
13
 
11
14
  override func layoutSubviews() {
12
15
  super.layoutSubviews()
16
+ updateRoundMask()
13
17
  onLayout?()
14
18
  }
15
19
 
@@ -17,6 +21,10 @@ private final class OneKeyImageHostView: SDAnimatedImageView {
17
21
  super.didMoveToWindow()
18
22
  onWindowChanged?(window != nil)
19
23
  }
24
+
25
+ private func updateRoundMask() {
26
+ layer.cornerRadius = round ? min(bounds.width, bounds.height) / 2 : 0
27
+ }
20
28
  }
21
29
 
22
30
  private final class OneKeyImageSkeletonView: UIView {
@@ -112,7 +120,9 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
112
120
  var view: UIView { hostView }
113
121
 
114
122
  var sourceUri: String? {
115
- didSet { if sourceUri != oldValue { identityDidChange() } }
123
+ didSet {
124
+ if sourceUri != oldValue { identityDidChange() }
125
+ }
116
126
  }
117
127
  var sourceHeadersJson: String? {
118
128
  didSet { if sourceHeadersJson != oldValue { identityDidChange() } }
@@ -134,11 +144,17 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
134
144
  var optimizeTos: Bool? = true {
135
145
  didSet { if optimizeTos != oldValue { identityDidChange() } }
136
146
  }
147
+ var round: Bool? = false
137
148
  var resizeWidth: Double? {
138
149
  didSet {
139
150
  if !Self.equalOptionalDouble(resizeWidth, oldValue) { identityDidChange() }
140
151
  }
141
152
  }
153
+ var resizeHeight: Double? {
154
+ didSet {
155
+ if !Self.equalOptionalDouble(resizeHeight, oldValue) { identityDidChange() }
156
+ }
157
+ }
142
158
  var overscan: Double? = 1.1 {
143
159
  didSet {
144
160
  if !Self.equalOptionalDouble(overscan, oldValue) { identityDidChange() }
@@ -185,6 +201,12 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
185
201
  }
186
202
 
187
203
  func afterUpdate() {
204
+ // Fabric can reset `round` before clearing `sourceUri` while removing a view.
205
+ // Apply the shape once per committed prop batch so the outgoing frame keeps
206
+ // its clipping, while mounted and recycled views still receive the new value.
207
+ if let sourceUri, !sourceUri.isEmpty {
208
+ hostView.round = round == true
209
+ }
188
210
  scheduleLoad()
189
211
  }
190
212
 
@@ -245,7 +267,10 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
245
267
  lastRequestSignature = nil
246
268
  // A memory-cached image shown from `identityDidChange` before layout
247
269
  // stays up; layout re-runs this load with real bounds and confirms it.
248
- if displayState != .image {
270
+ if !Self.shouldPreserveDisplayedImage(
271
+ isShowingImage: displayState == .image,
272
+ hasImage: hostView.image != nil
273
+ ) {
249
274
  showLoading(requestIsActive: false, letLibraryStartIndicator: false)
250
275
  }
251
276
  return
@@ -267,6 +292,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
267
292
  contentFit?.stringValue ?? "cover",
268
293
  String(optimizeTos ?? true),
269
294
  resizeWidth.map(String.init(describing:)) ?? "",
295
+ resizeHeight.map(String.init(describing:)) ?? "",
270
296
  String(overscan ?? 1.1),
271
297
  String(Int(hostView.bounds.width.rounded())),
272
298
  String(Int(hostView.bounds.height.rounded())),
@@ -282,7 +308,20 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
282
308
  onLoadStart?()
283
309
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else { return }
284
310
  if showMemoryCachedImageIfAvailable(requestIsActive: true) { return }
285
- showLoading(requestIsActive: true, letLibraryStartIndicator: true)
311
+ // The pre-layout probe may have shown this identity from another thumbnail
312
+ // key (a preload's, or an earlier layout size). Keep it on screen as the
313
+ // placeholder while the exact rendition loads: blanking to a skeleton and
314
+ // redrawing is worse than the skeleton it replaced.
315
+ let preservedImage =
316
+ Self.shouldPreserveDisplayedImage(
317
+ isShowingImage: displayState == .image,
318
+ hasImage: hostView.image != nil
319
+ ) ? hostView.image : nil
320
+ showLoading(
321
+ requestIsActive: true,
322
+ letLibraryStartIndicator: true,
323
+ preservedImage: preservedImage
324
+ )
286
325
 
287
326
  if let violation = OneKeyImageSafetyPolicy.preflight(source: rawString) {
288
327
  finishWithError(violation, generation: generation)
@@ -314,7 +353,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
314
353
  url: requestURL,
315
354
  rawURL: rawURL,
316
355
  generation: generation,
317
- mayFallbackToRaw: requestURL != rawURL
356
+ mayFallbackToRaw: requestURL != rawURL,
357
+ placeholderImage: preservedImage
318
358
  )
319
359
  }
320
360
 
@@ -322,7 +362,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
322
362
  url: URL,
323
363
  rawURL: URL,
324
364
  generation: UInt64,
325
- mayFallbackToRaw: Bool
365
+ mayFallbackToRaw: Bool,
366
+ placeholderImage: UIImage? = nil
326
367
  ) {
327
368
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else { return }
328
369
  let rawScreenScale: CGFloat = hostView.window?.screen.scale ?? UIScreen.main.scale
@@ -350,7 +391,9 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
350
391
  )
351
392
  hostView.sd_internalSetImage(
352
393
  with: url,
353
- placeholderImage: nil,
394
+ // SDWebImage resets the image view when it starts a load; hand it the
395
+ // preserved image so the view keeps showing it until the load lands.
396
+ placeholderImage: placeholderImage,
354
397
  options: OneKeyImageRequestContext.renderOptions,
355
398
  context: context,
356
399
  setImageBlock: { [weak self] image, _, _, _ in
@@ -502,6 +545,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
502
545
  autoplay = true
503
546
  recyclingKey = nil
504
547
  optimizeTos = true
548
+ round = false
505
549
  resizeWidth = nil
506
550
  overscan = 1.1
507
551
  loadingStrategy = .static
@@ -538,16 +582,32 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
538
582
 
539
583
  private func showLoading(
540
584
  requestIsActive: Bool,
541
- letLibraryStartIndicator: Bool
585
+ letLibraryStartIndicator: Bool,
586
+ preservedImage: UIImage? = nil
542
587
  ) {
543
588
  displayState = .loading
544
589
  requestActive = requestIsActive
545
590
  resetImageTransition()
546
- hostView.image = nil
547
591
  fallbackLayer.isHidden = true
592
+ if let preservedImage {
593
+ // Same identity, different thumbnail key: the image already on screen
594
+ // stands in for the placeholder, so no blank frame and no indicator.
595
+ hostView.image = preservedImage
596
+ skeletonIndicator.stopAnimatingIndicator()
597
+ hostView.backgroundColor = .clear
598
+ return
599
+ }
600
+ hostView.image = nil
548
601
  applyLoadingAppearance(letLibraryStartIndicator: letLibraryStartIndicator)
549
602
  }
550
603
 
604
+ /// Whether the image currently on screen should stay up while a load for the
605
+ /// SAME identity runs (bounds not laid out yet, or the laid-out request key
606
+ /// differs from the one the pre-layout probe hit). Pure for testability.
607
+ static func shouldPreserveDisplayedImage(isShowingImage: Bool, hasImage: Bool) -> Bool {
608
+ isShowingImage && hasImage
609
+ }
610
+
551
611
  private func showMemoryCachedImageIfAvailable(requestIsActive: Bool) -> Bool {
552
612
  // JS leaves `cachePolicy` unset for the common case and Fabric then hands
553
613
  // Nitro a nil, which the request path already treats as memory-disk; the
@@ -575,20 +635,21 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
575
635
  hasCustomIdentity: hasCustomIdentity
576
636
  )
577
637
  : rawURL
578
- // Before layout the bounds are empty; fall back to the `resizeWidth` hint so
579
- // the probe key matches the prewarm / post-layout request instead of
580
- // missing on every mount frame.
581
- let thumbnailPixelSize = OneKeyImageDecodeSizing.probeViewSize(
582
- bounds: hostView.bounds.size,
583
- resizeWidth: resizeWidth
584
- ).flatMap {
585
- OneKeyImageDecodeSizing.thumbnailPixelSize(
586
- viewSize: $0,
638
+ // The thumbnail size is part of the cache key. Laid out, it is this view's
639
+ // own request key; before layout it comes from the resize hints, under
640
+ // this view's contentFit and under `.cover` (a preload's key). With no
641
+ // bounds and no hint the un-thumbnailed key is the only thing to try.
642
+ let thumbnailCandidates: [CGSize?] = {
643
+ let sizes = OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
644
+ bounds: hostView.bounds.size,
645
+ resizeWidth: resizeWidth,
646
+ resizeHeight: resizeHeight,
587
647
  scale: screenScale,
588
648
  contentFit: contentFit ?? .cover
589
649
  )
590
- }
591
- func memoryCachedImage(for url: URL) -> UIImage? {
650
+ return sizes.isEmpty ? [nil] : sizes
651
+ }()
652
+ func memoryCachedImage(for url: URL, thumbnailPixelSize: CGSize?) -> UIImage? {
592
653
  let context = OneKeyImageRequestContext.make(
593
654
  headersJson: sourceHeadersJson,
594
655
  cachePolicy: cachePolicy ?? .memoryDisk,
@@ -603,6 +664,14 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
603
664
  }
604
665
  return cache.imageFromMemoryCache(forKey: key)
605
666
  }
667
+ func memoryCachedImage(for url: URL) -> UIImage? {
668
+ for thumbnailPixelSize in thumbnailCandidates {
669
+ if let image = memoryCachedImage(for: url, thumbnailPixelSize: thumbnailPixelSize) {
670
+ return image
671
+ }
672
+ }
673
+ return nil
674
+ }
606
675
  guard let image = memoryCachedImage(for: requestURL)
607
676
  ?? (requestURL == rawURL ? nil : memoryCachedImage(for: rawURL)) else {
608
677
  return false
@@ -610,17 +610,42 @@ enum OneKeyImageDecodeSizing {
610
610
  }
611
611
  }
612
612
 
613
- /// The logical size to probe the memory cache with. Before the first layout
614
- /// the host view has no bounds, but the JS side already knows the display
615
- /// size through `resizeWidth` (the same hint the prewarm used), so a square
616
- /// from it yields the decode-thumbnail key the laid-out view will ask for.
617
- /// A cached image can then show on the mount frame instead of a skeleton
618
- /// until layout re-probes.
619
- static func probeViewSize(bounds: CGSize, resizeWidth: Double?) -> CGSize? {
613
+ /// Decode-thumbnail sizes worth probing the memory cache with, most specific
614
+ /// first. The thumbnail size is part of the SDWebImage cache key, so a probe
615
+ /// only hits when it reproduces the key some earlier load stored under.
616
+ ///
617
+ /// - Laid out: exactly the size this view's own request uses (`bounds` +
618
+ /// `contentFit`), nothing else.
619
+ /// - Before layout the bounds are empty, but the JS side already knows the
620
+ /// display size through the `resizeWidth` / `resizeHeight` hints (the same
621
+ /// fields a preload takes). Two keys can hold the image then: the one the
622
+ /// laid-out request will ask for (hint + this view's `contentFit`) and the
623
+ /// one a preload stored (hint + `.cover`, see `HybridOneKeyImageCache`).
624
+ /// They coincide for a square hint or `.cover`; otherwise both are probed.
625
+ /// - No bounds and no usable hint: nothing to probe with.
626
+ static func probeThumbnailPixelSizes(
627
+ bounds: CGSize,
628
+ resizeWidth: Double?,
629
+ resizeHeight: Double?,
630
+ scale: CGFloat,
631
+ contentFit: OneKeyImageContentFit
632
+ ) -> [CGSize] {
620
633
  if bounds.width.isFinite, bounds.height.isFinite, bounds.width > 0, bounds.height > 0 {
621
- return bounds
634
+ return thumbnailPixelSize(viewSize: bounds, scale: scale, contentFit: contentFit)
635
+ .map { [$0] } ?? []
636
+ }
637
+ guard let hint = logicalViewSize(width: resizeWidth, height: resizeHeight) else {
638
+ return []
639
+ }
640
+ var sizes: [CGSize] = []
641
+ for fit in [contentFit, .cover] {
642
+ if let size = thumbnailPixelSize(viewSize: hint, scale: scale, contentFit: fit),
643
+ !sizes.contains(size)
644
+ {
645
+ sizes.append(size)
646
+ }
622
647
  }
623
- return logicalViewSize(width: resizeWidth, height: nil)
648
+ return sizes
624
649
  }
625
650
 
626
651
  static func thumbnailPixelSize(
@@ -310,31 +310,89 @@ final class OneKeyImageInfrastructureTests: XCTestCase {
310
310
  )
311
311
  }
312
312
 
313
- func testMemoryProbeFallsBackToResizeWidthBeforeLayout() throws {
314
- // Laid out: the real bounds win, even with a hint.
313
+ // The pre-layout memory probe is only useful if it reproduces a key some
314
+ // earlier load stored under. These pin the probe against the two real key
315
+ // producers: the laid-out render request (bounds + contentFit) and the
316
+ // preload (logicalViewSize(resizeWidth, resizeHeight) + .cover).
317
+ private func renderKey(bounds: CGSize, fit: OneKeyImageContentFit) throws -> CGSize {
318
+ try XCTUnwrap(
319
+ OneKeyImageDecodeSizing.thumbnailPixelSize(viewSize: bounds, scale: 3, contentFit: fit)
320
+ )
321
+ }
322
+
323
+ private func preloadKey(width: Double?, height: Double?) throws -> CGSize {
324
+ try renderKey(
325
+ bounds: try XCTUnwrap(OneKeyImageDecodeSizing.logicalViewSize(width: width, height: height)),
326
+ fit: .cover
327
+ )
328
+ }
329
+
330
+ func testLaidOutProbeUsesExactlyTheRenderRequestKey() throws {
331
+ let bounds = CGSize(width: 40, height: 64)
332
+ for fit in [OneKeyImageContentFit.cover, .contain, .fill, .center] {
333
+ XCTAssertEqual(
334
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
335
+ bounds: bounds, resizeWidth: 32, resizeHeight: 48, scale: 3, contentFit: fit
336
+ ),
337
+ [try renderKey(bounds: bounds, fit: fit)],
338
+ "\(fit)"
339
+ )
340
+ }
341
+ }
342
+
343
+ func testPreLayoutSquareHintMatchesPreloadAndRenderKeys() throws {
344
+ // A token icon: resizeWidth only, laid out square, cover. One key, shared
345
+ // by the preload, the laid-out request and the probe.
346
+ let sizes = OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
347
+ bounds: .zero, resizeWidth: 40, resizeHeight: nil, scale: 3, contentFit: .cover
348
+ )
349
+ XCTAssertEqual(sizes, [CGSize(width: 120, height: 120)])
350
+ XCTAssertEqual(sizes, [try preloadKey(width: 40, height: nil)])
351
+ XCTAssertEqual(sizes, [try renderKey(bounds: CGSize(width: 40, height: 40), fit: .cover)])
352
+ }
353
+
354
+ func testPreLayoutRectangularHintProbesRenderAndPreloadKeys() throws {
355
+ // The README preload shape (48x64 @3) under a non-cover view: the laid-out
356
+ // request key comes first, the preload's cover key second, no duplicates.
357
+ let sizes = OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
358
+ bounds: .zero, resizeWidth: 48, resizeHeight: 64, scale: 3, contentFit: .contain
359
+ )
315
360
  XCTAssertEqual(
316
- OneKeyImageDecodeSizing.probeViewSize(
317
- bounds: CGSize(width: 40, height: 64),
318
- resizeWidth: 32
361
+ sizes,
362
+ [
363
+ try renderKey(bounds: CGSize(width: 48, height: 64), fit: .contain),
364
+ try preloadKey(width: 48, height: 64),
365
+ ]
366
+ )
367
+ XCTAssertEqual(sizes, [CGSize(width: 144, height: 192), CGSize(width: 192, height: 192)])
368
+ // Under cover both collapse to the preload key.
369
+ XCTAssertEqual(
370
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
371
+ bounds: .zero, resizeWidth: 48, resizeHeight: 64, scale: 3, contentFit: .cover
319
372
  ),
320
- CGSize(width: 40, height: 64)
373
+ [try preloadKey(width: 48, height: 64)]
321
374
  )
322
- // Not laid out yet: a square from the hint, matching the prewarm key.
323
- let probe = try XCTUnwrap(
324
- OneKeyImageDecodeSizing.probeViewSize(bounds: .zero, resizeWidth: 40)
375
+ }
376
+
377
+ func testPreLayoutProbeWithoutHintHasNothingToTry() {
378
+ XCTAssertEqual(
379
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
380
+ bounds: .zero, resizeWidth: nil, resizeHeight: nil, scale: 3, contentFit: .cover
381
+ ),
382
+ []
325
383
  )
326
- XCTAssertEqual(probe, CGSize(width: 40, height: 40))
327
384
  XCTAssertEqual(
328
- OneKeyImageDecodeSizing.thumbnailPixelSize(viewSize: probe, scale: 3, contentFit: .cover),
329
- OneKeyImageDecodeSizing.thumbnailPixelSize(
330
- viewSize: try XCTUnwrap(OneKeyImageDecodeSizing.logicalViewSize(width: 40, height: nil)),
331
- scale: 3,
332
- contentFit: .cover
333
- )
385
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
386
+ bounds: .zero, resizeWidth: 0, resizeHeight: -1, scale: 3, contentFit: .cover
387
+ ),
388
+ []
334
389
  )
335
- // No bounds and no usable hint: nothing to probe with.
336
- XCTAssertNil(OneKeyImageDecodeSizing.probeViewSize(bounds: .zero, resizeWidth: nil))
337
- XCTAssertNil(OneKeyImageDecodeSizing.probeViewSize(bounds: .zero, resizeWidth: 0))
390
+ }
391
+
392
+ func testDisplayedImageIsPreservedOnlyWhileShowingOne() {
393
+ XCTAssertTrue(HybridOneKeyImage.shouldPreserveDisplayedImage(isShowingImage: true, hasImage: true))
394
+ XCTAssertFalse(HybridOneKeyImage.shouldPreserveDisplayedImage(isShowingImage: true, hasImage: false))
395
+ XCTAssertFalse(HybridOneKeyImage.shouldPreserveDisplayedImage(isShowingImage: false, hasImage: true))
338
396
  }
339
397
 
340
398
  func testNoSizePreloadUsesCappedDecodeTarget() {
@@ -592,4 +650,50 @@ final class OneKeyImageInfrastructureTests: XCTestCase {
592
650
  )
593
651
  XCTAssertTrue(imageView.clearBufferWhenStopped)
594
652
  }
653
+
654
+ func testRoundPropUpdatesNativeCornerRadius() throws {
655
+ let image = HybridOneKeyImage()
656
+ let imageView = try XCTUnwrap(image.view as? SDAnimatedImageView)
657
+ imageView.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
658
+
659
+ image.sourceUri = "https://example.com/round.png"
660
+ image.round = true
661
+ image.afterUpdate()
662
+ imageView.layoutIfNeeded()
663
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
664
+
665
+ image.round = nil
666
+ image.afterUpdate()
667
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
668
+
669
+ image.round = false
670
+ image.afterUpdate()
671
+ XCTAssertEqual(imageView.layer.cornerRadius, 0)
672
+ }
673
+
674
+ func testRecycleKeepsRoundMaskUntilVisibleViewDetaches() throws {
675
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
676
+ let image = HybridOneKeyImage()
677
+ let imageView = try XCTUnwrap(image.view as? SDAnimatedImageView)
678
+ imageView.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
679
+ window.addSubview(imageView)
680
+ image.sourceUri = "https://example.com/round.png"
681
+ image.round = true
682
+ image.afterUpdate()
683
+ imageView.layoutIfNeeded()
684
+
685
+ // Fabric resets props in setter order before the view leaves the hierarchy.
686
+ image.round = false
687
+ image.sourceUri = nil
688
+ image.afterUpdate()
689
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
690
+
691
+ image.prepareForRecycle()
692
+
693
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
694
+
695
+ image.sourceUri = "https://example.com/reused.png"
696
+ image.afterUpdate()
697
+ XCTAssertEqual(imageView.layer.cornerRadius, 0)
698
+ }
595
699
  }