@onekeyfe/react-native-image 3.0.155 → 3.0.157-alpha.248

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 (44) hide show
  1. package/README.md +3 -0
  2. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +356 -22
  3. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +34 -4
  4. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariants.kt +119 -0
  5. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +2 -0
  6. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariantsTest.kt +149 -0
  7. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +14 -1
  8. package/ios/OneKeyImage.swift +273 -43
  9. package/ios/OneKeyImageCache.swift +40 -3
  10. package/ios/OneKeyImageMemoryVariants.swift +137 -0
  11. package/ios/OneKeyImageRequestContext.swift +51 -9
  12. package/ios/tests/OneKeyImageInfrastructureTests.swift +255 -18
  13. package/lib/module/index.js +26 -3
  14. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  15. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  16. package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  17. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  18. package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  19. package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  20. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  21. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  22. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  23. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  24. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  25. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  26. package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  27. package/lib/typescript/src/OneKeyImage.nitro.d.ts +8 -0
  28. package/lib/typescript/src/index.d.ts +1 -1
  29. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  30. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  31. package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  33. package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  34. package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  35. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  36. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  37. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  38. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  39. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  40. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  41. package/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  42. package/package.json +2 -2
  43. package/src/OneKeyImage.nitro.ts +8 -0
  44. package/src/index.tsx +25 -1
package/README.md CHANGED
@@ -5,6 +5,9 @@ native loading placeholders, opt-in shared-renderer skeleton, fallback states,
5
5
  cache controls,
6
6
  and conservative OneKey TOS URL resizing without depending on Expo Image.
7
7
 
8
+ The authoritative lifecycle, cache identity, cross-size memory-preview, safety,
9
+ and platform contract is defined in [docs/SPEC.md](docs/SPEC.md).
10
+
8
11
  ```tsx
9
12
  <OneKeyImage
10
13
  source={{ uri: 'https://example.com/avatar.png' }}
@@ -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
@@ -23,6 +26,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
23
26
  import com.bumptech.glide.load.engine.GlideException
24
27
  import com.bumptech.glide.request.RequestListener
25
28
  import com.bumptech.glide.request.RequestOptions
29
+ import com.bumptech.glide.request.target.CustomTarget
26
30
  import com.bumptech.glide.request.target.CustomViewTarget
27
31
  import com.bumptech.glide.request.target.Target
28
32
  import com.bumptech.glide.request.transition.Transition
@@ -43,7 +47,45 @@ private fun Context.findActivity(): Activity? {
43
47
  return currentContext as? Activity
44
48
  }
45
49
 
50
+ internal fun shouldScaleCenterMemoryPreview(
51
+ contentFit: OneKeyImageContentFit,
52
+ candidate: OneKeyImageMemoryVariant,
53
+ target: OneKeyImageMemoryVariant,
54
+ ): Boolean =
55
+ contentFit == OneKeyImageContentFit.CENTER &&
56
+ (candidate.width != target.width || candidate.height != target.height)
57
+
58
+ internal fun shouldProbeMemoryPreview(
59
+ hasTarget: Boolean,
60
+ sameFamily: Boolean,
61
+ hasDrawable: Boolean,
62
+ ): Boolean = !hasTarget || !sameFamily || !hasDrawable
63
+
64
+ internal fun shouldAnimateLoadedImageTransition(
65
+ cacheType: OneKeyImageCacheType,
66
+ fadeDelayElapsed: Boolean,
67
+ animationsEnabled: Boolean,
68
+ replacingMemoryPreview: Boolean,
69
+ ): Boolean =
70
+ !replacingMemoryPreview && cacheType != OneKeyImageCacheType.MEMORY &&
71
+ fadeDelayElapsed && animationsEnabled
72
+
73
+ internal fun memoryProbeRequestOptions(base: RequestOptions): RequestOptions =
74
+ base
75
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
76
+ .onlyRetrieveFromCache(true)
77
+
46
78
  private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(context) {
79
+ private val roundClipPath = Path()
80
+ private val roundOutlineProvider = object : ViewOutlineProvider() {
81
+ override fun getOutline(view: View, outline: Outline) {
82
+ if (view.width > 0 && view.height > 0) {
83
+ outline.setOval(0, 0, view.width, view.height)
84
+ } else {
85
+ outline.setEmpty()
86
+ }
87
+ }
88
+ }
47
89
  private val fallbackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
48
90
  color = Color.rgb(110, 110, 120)
49
91
  textAlign = Paint.Align.CENTER
@@ -52,6 +94,16 @@ private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(conte
52
94
  var drawStateSymbol = false
53
95
  var stateSymbol = "◇"
54
96
  var autoplayEnabled = false
97
+ var round = false
98
+ set(value) {
99
+ if (field == value) return
100
+ field = value
101
+ outlineProvider = if (value) roundOutlineProvider else ViewOutlineProvider.BACKGROUND
102
+ clipToOutline = value
103
+ updateRoundClipPath()
104
+ invalidateOutline()
105
+ invalidate()
106
+ }
55
107
  private var aggregatedVisible = true
56
108
  var skeletonRequested = false
57
109
  set(value) {
@@ -65,9 +117,24 @@ private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(conte
65
117
  super.onSizeChanged(w, h, oldw, oldh)
66
118
  skeleton.updateBounds(w, h)
67
119
  fallbackPaint.textSize = minOf(w, h) * 0.35f
120
+ if (round) {
121
+ updateRoundClipPath()
122
+ invalidateOutline()
123
+ }
68
124
  if (w > 0 && h > 0) onReadyForRequest?.invoke()
69
125
  }
70
126
 
127
+ override fun draw(canvas: Canvas) {
128
+ if (!round || roundClipPath.isEmpty) {
129
+ super.draw(canvas)
130
+ return
131
+ }
132
+ val saveCount = canvas.save()
133
+ canvas.clipPath(roundClipPath)
134
+ super.draw(canvas)
135
+ canvas.restoreToCount(saveCount)
136
+ }
137
+
71
138
  override fun onDraw(canvas: Canvas) {
72
139
  super.onDraw(canvas)
73
140
  if (drawStateSymbol) {
@@ -108,6 +175,13 @@ private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(conte
108
175
  skeleton.updateBounds(width, height)
109
176
  }
110
177
 
178
+ private fun updateRoundClipPath() {
179
+ roundClipPath.reset()
180
+ if (round && width > 0 && height > 0) {
181
+ roundClipPath.addOval(0f, 0f, width.toFloat(), height.toFloat(), Path.Direction.CW)
182
+ }
183
+ }
184
+
111
185
  fun syncPlayback() {
112
186
  val canRun = isAttachedToWindow && aggregatedVisible
113
187
  if (skeletonRequested && canRun) skeleton.start() else skeleton.stop()
@@ -130,10 +204,12 @@ internal fun oneKeyImageRequestSignature(
130
204
  contentFit: OneKeyImageContentFit?,
131
205
  optimizeTos: Boolean?,
132
206
  overscan: Double?,
207
+ round: Boolean,
133
208
  width: Int,
134
209
  height: Int,
135
210
  density: Float,
136
211
  resizeWidth: Double? = null,
212
+ resizeHeight: Double? = null,
137
213
  ): String = listOf(
138
214
  rawUrl,
139
215
  sourceHeadersJson.orEmpty(),
@@ -142,7 +218,9 @@ internal fun oneKeyImageRequestSignature(
142
218
  (contentFit ?: OneKeyImageContentFit.COVER).name,
143
219
  optimizeTos.toString(),
144
220
  overscan.toString(),
221
+ round.toString(),
145
222
  resizeWidth.toString(),
223
+ resizeHeight.toString(),
146
224
  width.toString(),
147
225
  height.toString(),
148
226
  density.toString(),
@@ -166,12 +244,16 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
166
244
  }
167
245
  private var loadRunnable: Runnable? = null
168
246
  private var currentTarget: CustomViewTarget<OneKeyImageHostView, Drawable>? = null
247
+ private var memoryPreviewTarget: CustomTarget<Drawable>? = null
248
+ private var memoryPreviewFamily: OneKeyImageMemoryFamilyKey? = null
249
+ private var scalesCenterMemoryPreview = false
169
250
  private var generation = 0L
170
251
  private var lastSignature: String? = null
171
252
  private var disposed = false
172
253
  private var suppressPropEffects = false
173
254
  private var requestActive = false
174
255
  private var displayState = DisplayState.LOADING
256
+ private var isPreservingDisplayedImage = false
175
257
  private var displayRunnable: Runnable? = null
176
258
  private var pendingDisplayGeneration: Long? = null
177
259
  private var fallbackRunnable: Runnable? = null
@@ -241,6 +323,10 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
241
323
  field = value
242
324
  requestIdentityChanged()
243
325
  }
326
+ override var round: Boolean? = false
327
+ set(value) {
328
+ field = value
329
+ }
244
330
  override var overscan: Double? = 1.1
245
331
  set(value) {
246
332
  if (field == value) return
@@ -253,6 +339,14 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
253
339
  field = value
254
340
  requestIdentityChanged()
255
341
  }
342
+ // Layout hint pair with resizeWidth; the pre-layout memory probe uses both
343
+ // values to reproduce a preload's physical decode size.
344
+ override var resizeHeight: Double? = null
345
+ set(value) {
346
+ if (field == value) return
347
+ field = value
348
+ requestIdentityChanged()
349
+ }
256
350
  override var loadingStrategy: OneKeyImageLoadingStrategy? = OneKeyImageLoadingStrategy.STATIC
257
351
  set(value) {
258
352
  field = value
@@ -287,6 +381,10 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
287
381
 
288
382
  override fun afterUpdate() {
289
383
  super.afterUpdate()
384
+ // Fabric can reset `round` before clearing `sourceUri` while removing a view.
385
+ // Apply the shape once per committed prop batch so the outgoing frame keeps
386
+ // its clipping, while mounted and recycled views still receive the new value.
387
+ if (!sourceUri.isNullOrBlank()) hostView.round = round == true
290
388
  loadForCurrentLayout()
291
389
  }
292
390
 
@@ -330,7 +428,11 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
330
428
  if (suppressPropEffects || disposed) return
331
429
  lastSignature = null
332
430
  cancelCurrent(invalidateGeneration = true)
333
- if (sourceUri.isNullOrBlank()) showFallback() else showLoading(requestIsActive = false)
431
+ if (sourceUri.isNullOrBlank()) {
432
+ showFallback()
433
+ } else if (!showMemoryCachedPreviewBeforeLayout()) {
434
+ showLoading(requestIsActive = false)
435
+ }
334
436
  scheduleLoad()
335
437
  }
336
438
 
@@ -346,7 +448,9 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
346
448
  autoplay = false
347
449
  recyclingKey = null
348
450
  optimizeTos = true
451
+ round = false
349
452
  resizeWidth = null
453
+ resizeHeight = null
350
454
  overscan = 1.1
351
455
  loadingStrategy = OneKeyImageLoadingStrategy.STATIC
352
456
  placeholderColor = null
@@ -358,6 +462,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
358
462
  suppressPropEffects = false
359
463
  requestActive = false
360
464
  displayState = DisplayState.LOADING
465
+ isPreservingDisplayedImage = false
361
466
  hostView.setImageDrawable(null)
362
467
  hostView.drawStateSymbol = false
363
468
  hostView.skeletonRequested = false
@@ -393,12 +498,147 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
393
498
  }
394
499
  }
395
500
 
501
+ private fun showMemoryCachedPreviewBeforeLayout(): Boolean {
502
+ if (Looper.myLooper() != Looper.getMainLooper()) return false
503
+ val rawUrl = sourceUri?.takeIf { it.isNotBlank() } ?: return false
504
+ val policy = cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK
505
+ if (
506
+ policy != OneKeyImageCachePolicy.MEMORY &&
507
+ policy != OneKeyImageCachePolicy.MEMORY_DISK
508
+ ) {
509
+ return false
510
+ }
511
+ val displayWidth = resizeWidth?.takeIf { it.isFinite() && it > 0.0 }
512
+ val displayHeight = resizeHeight?.takeIf { it.isFinite() && it > 0.0 }
513
+ if (displayWidth == null && displayHeight == null) return false
514
+ val density = hostView.resources.displayMetrics.density.coerceAtLeast(1f)
515
+ val displaySize = when {
516
+ displayWidth != null && displayHeight != null -> maxOf(displayWidth, displayHeight)
517
+ displayWidth != null -> displayWidth
518
+ else -> requireNotNull(displayHeight)
519
+ }
520
+ val customIdentity = OneKeyImageModel.headers(sourceHeadersJson).isNotEmpty()
521
+ val requestUrl = if (optimizeTos != false) {
522
+ OneKeyTosUrl.optimized(
523
+ rawUrl,
524
+ ceil(displaySize).toInt(),
525
+ density,
526
+ overscan ?: 1.1,
527
+ customIdentity,
528
+ )
529
+ } else rawUrl
530
+ val dimensions = OneKeyImageDecodeDimensions.forPreload(
531
+ displayWidth,
532
+ displayHeight,
533
+ density.toDouble(),
534
+ )
535
+ return showMemoryCachedPreview(
536
+ family = OneKeyImageMemoryFamilyKey.from(rawUrl, sourceHeadersJson),
537
+ exactVariant = OneKeyImageMemoryVariant(
538
+ requestUrl = requestUrl,
539
+ width = dimensions.width,
540
+ height = dimensions.height,
541
+ round = round == true,
542
+ ),
543
+ includeExact = true,
544
+ policy = policy,
545
+ requestGeneration = generation,
546
+ )
547
+ }
548
+
549
+ private fun showMemoryCachedPreview(
550
+ family: OneKeyImageMemoryFamilyKey,
551
+ exactVariant: OneKeyImageMemoryVariant,
552
+ includeExact: Boolean,
553
+ policy: OneKeyImageCachePolicy,
554
+ requestGeneration: Long,
555
+ ): Boolean {
556
+ if (
557
+ Looper.myLooper() != Looper.getMainLooper() ||
558
+ disposed ||
559
+ requestGeneration != generation ||
560
+ (policy != OneKeyImageCachePolicy.MEMORY &&
561
+ policy != OneKeyImageCachePolicy.MEMORY_DISK)
562
+ ) {
563
+ return false
564
+ }
565
+ val candidates = buildList {
566
+ if (includeExact) {
567
+ add(exactVariant)
568
+ if (exactVariant.requestUrl != family.rawUrl) {
569
+ add(exactVariant.copy(requestUrl = family.rawUrl))
570
+ }
571
+ }
572
+ addAll(OneKeyImageMemoryVariantRegistry.candidates(family, exactVariant))
573
+ }.distinct()
574
+
575
+ for (candidate in candidates) {
576
+ var synchronousResource: Drawable? = null
577
+ val target = object : CustomTarget<Drawable>(candidate.width, candidate.height) {
578
+ override fun onResourceReady(
579
+ resource: Drawable,
580
+ transition: Transition<in Drawable>?,
581
+ ) {
582
+ if (
583
+ requestGeneration == generation &&
584
+ !disposed &&
585
+ resource !is Animatable
586
+ ) {
587
+ synchronousResource = resource
588
+ }
589
+ }
590
+
591
+ override fun onLoadCleared(placeholder: Drawable?) = Unit
592
+ }
593
+ requestManager()
594
+ .asDrawable()
595
+ .load(OneKeyImageModel.build(candidate.requestUrl, sourceHeadersJson))
596
+ .apply(
597
+ memoryProbeRequestOptions(
598
+ requestOptions(policy, candidate.requestUrl, candidate.round),
599
+ ),
600
+ )
601
+ .override(candidate.width, candidate.height)
602
+ .into(target)
603
+
604
+ val resource = synchronousResource
605
+ if (resource == null) {
606
+ requestManager().clear(target)
607
+ OneKeyImageMemoryVariantRegistry.remove(family, candidate)
608
+ continue
609
+ }
610
+
611
+ clearMemoryPreviewTarget()
612
+ memoryPreviewTarget = target
613
+ memoryPreviewFamily = family
614
+ scalesCenterMemoryPreview = shouldScaleCenterMemoryPreview(
615
+ contentFit = contentFit ?: OneKeyImageContentFit.COVER,
616
+ candidate = candidate,
617
+ target = exactVariant,
618
+ )
619
+ applyContentFit()
620
+ displayState = DisplayState.IMAGE
621
+ requestActive = false
622
+ isPreservingDisplayedImage = false
623
+ hostView.skeletonRequested = false
624
+ hostView.drawStateSymbol = false
625
+ hostView.setBackgroundColor(Color.TRANSPARENT)
626
+ hostView.setImageDrawable(resource)
627
+ applyAutoplay()
628
+ OneKeyImageMemoryVariantRegistry.record(family, candidate)
629
+ return true
630
+ }
631
+ return false
632
+ }
633
+
396
634
  private fun startLoad(force: Boolean) {
397
635
  if (disposed) return
398
636
  if (hostView.width <= 0 || hostView.height <= 0) {
399
- cancelCurrent(invalidateGeneration = true)
637
+ cancelCurrent(invalidateGeneration = true, clearMemoryPreview = false)
400
638
  lastSignature = null
401
- showLoading(requestIsActive = false)
639
+ if (memoryPreviewTarget == null || hostView.drawable == null) {
640
+ showLoading(requestIsActive = false)
641
+ }
402
642
  return
403
643
  }
404
644
  val rawUrl = sourceUri?.takeIf { it.isNotBlank() }
@@ -416,15 +656,17 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
416
656
  contentFit = contentFit,
417
657
  optimizeTos = optimizeTos,
418
658
  overscan = overscan,
659
+ round = round == true,
419
660
  width = hostView.width,
420
661
  height = hostView.height,
421
662
  density = hostView.resources.displayMetrics.density,
422
663
  resizeWidth = resizeWidth,
664
+ resizeHeight = resizeHeight,
423
665
  )
424
666
  if (!force && signature == lastSignature) return
425
667
  lastSignature = signature
426
668
 
427
- cancelCurrent(invalidateGeneration = true)
669
+ cancelCurrent(invalidateGeneration = true, clearMemoryPreview = false)
428
670
  val requestGeneration = generation
429
671
  requestStartedAtMs = SystemClock.uptimeMillis()
430
672
  onLoadStart?.invoke()
@@ -432,8 +674,14 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
432
674
  val customIdentity = OneKeyImageModel.headers(sourceHeadersJson).isNotEmpty()
433
675
  val optimizedUrl = if (optimizeTos != false) {
434
676
  val density = hostView.resources.displayMetrics.density.coerceAtLeast(1f)
435
- val displaySize = resizeWidth?.takeIf { it.isFinite() && it > 0.0 }
436
- ?: (maxOf(hostView.width, hostView.height) / density.toDouble())
677
+ val displayWidth = resizeWidth?.takeIf { it.isFinite() && it > 0.0 }
678
+ val displayHeight = resizeHeight?.takeIf { it.isFinite() && it > 0.0 }
679
+ val displaySize = when {
680
+ displayWidth != null && displayHeight != null -> maxOf(displayWidth, displayHeight)
681
+ displayWidth != null -> displayWidth
682
+ displayHeight != null -> displayHeight
683
+ else -> maxOf(hostView.width, hostView.height) / density.toDouble()
684
+ }
437
685
  OneKeyTosUrl.optimized(
438
686
  rawUrl,
439
687
  ceil(displaySize).toInt(),
@@ -443,6 +691,28 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
443
691
  )
444
692
  } else rawUrl
445
693
  val decodeDimensions = OneKeyImageDecodeDimensions.forRender(hostView.width, hostView.height)
694
+ val memoryFamily = OneKeyImageMemoryFamilyKey.from(rawUrl, sourceHeadersJson)
695
+ val exactVariant = OneKeyImageMemoryVariant(
696
+ requestUrl = optimizedUrl,
697
+ width = decodeDimensions.width,
698
+ height = decodeDimensions.height,
699
+ round = round == true,
700
+ )
701
+ if (
702
+ shouldProbeMemoryPreview(
703
+ hasTarget = memoryPreviewTarget != null,
704
+ sameFamily = memoryPreviewFamily == memoryFamily,
705
+ hasDrawable = hostView.drawable != null,
706
+ )
707
+ ) {
708
+ showMemoryCachedPreview(
709
+ family = memoryFamily,
710
+ exactVariant = exactVariant,
711
+ includeExact = false,
712
+ policy = cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK,
713
+ requestGeneration = requestGeneration,
714
+ )
715
+ }
446
716
  performRequest(
447
717
  requestUrl = optimizedUrl,
448
718
  rawUrl = rawUrl,
@@ -451,6 +721,8 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
451
721
  headersJson = sourceHeadersJson,
452
722
  policy = cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK,
453
723
  decodeDimensions = decodeDimensions,
724
+ memoryFamily = memoryFamily,
725
+ memoryVariant = exactVariant,
454
726
  )
455
727
  }
456
728
 
@@ -462,6 +734,8 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
462
734
  headersJson: String?,
463
735
  policy: OneKeyImageCachePolicy,
464
736
  decodeDimensions: OneKeyImageDecodeDimensions,
737
+ memoryFamily: OneKeyImageMemoryFamilyKey,
738
+ memoryVariant: OneKeyImageMemoryVariant,
465
739
  ) {
466
740
  if (requestGeneration != generation || disposed) return
467
741
  clearCurrentTarget()
@@ -471,18 +745,34 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
471
745
  val target = object : CustomViewTarget<OneKeyImageHostView, Drawable>(hostView) {
472
746
  override fun onResourceLoading(placeholder: Drawable?) {
473
747
  if (requestGeneration != generation || disposed) return
474
- showLoading(requestIsActive = true)
748
+ showLoading(
749
+ requestIsActive = true,
750
+ preserveDisplayedImage = memoryPreviewTarget != null && hostView.drawable != null,
751
+ )
475
752
  }
476
753
 
477
754
  override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
478
755
  if (requestGeneration != generation || disposed) return
756
+ val replacesMemoryPreview = memoryPreviewTarget != null && hostView.drawable != null
479
757
  requestActive = false
480
758
  displayState = DisplayState.IMAGE
481
759
  hostView.skeletonRequested = false
482
760
  hostView.drawStateSymbol = false
483
761
  hostView.setBackgroundColor(Color.TRANSPARENT)
484
762
  hostView.setImageDrawable(resource)
485
- applyLoadedImageTransition(resolvedCacheType)
763
+ isPreservingDisplayedImage = false
764
+ if (
765
+ resource !is Animatable &&
766
+ (policy == OneKeyImageCachePolicy.MEMORY ||
767
+ policy == OneKeyImageCachePolicy.MEMORY_DISK)
768
+ ) {
769
+ OneKeyImageMemoryVariantRegistry.record(memoryFamily, memoryVariant)
770
+ }
771
+ clearMemoryPreviewTarget()
772
+ applyLoadedImageTransition(
773
+ cacheType = resolvedCacheType,
774
+ replacingMemoryPreview = replacesMemoryPreview,
775
+ )
486
776
  applyAutoplay()
487
777
  val loadCallback = onLoad
488
778
  val loadEndCallback = onLoadEnd
@@ -534,6 +824,8 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
534
824
  headersJson = headersJson,
535
825
  policy = policy,
536
826
  decodeDimensions = decodeDimensions,
827
+ memoryFamily = memoryFamily,
828
+ memoryVariant = memoryVariant.copy(requestUrl = rawUrl),
537
829
  )
538
830
  }
539
831
  }
@@ -549,7 +841,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
549
841
  requestManager()
550
842
  .asDrawable()
551
843
  .load(OneKeyImageModel.build(requestUrl, headersJson))
552
- .apply(requestOptions(policy, requestUrl))
844
+ .apply(requestOptions(policy, requestUrl, memoryVariant.round))
553
845
  .override(decodeDimensions.width, decodeDimensions.height)
554
846
  .listener(object : RequestListener<Drawable> {
555
847
  override fun onLoadFailed(
@@ -577,11 +869,15 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
577
869
  .into(target)
578
870
  }
579
871
 
580
- private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions {
872
+ private fun requestOptions(
873
+ policy: OneKeyImageCachePolicy,
874
+ uri: String,
875
+ round: Boolean,
876
+ ): RequestOptions {
581
877
  val options = RequestOptions()
582
878
  .withOneKeyAvatarCache(uri)
583
- .dontTransform()
584
879
  .downsample(OneKeyImageSafeDownsampleStrategy)
880
+ if (round) options.circleCrop() else options.dontTransform()
585
881
  return when (policy) {
586
882
  OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri))
587
883
  OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE)
@@ -605,7 +901,10 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
605
901
  )
606
902
  }
607
903
 
608
- private fun cancelCurrent(invalidateGeneration: Boolean) {
904
+ private fun cancelCurrent(
905
+ invalidateGeneration: Boolean,
906
+ clearMemoryPreview: Boolean = true,
907
+ ) {
609
908
  loadRunnable?.let(hostView::removeCallbacks)
610
909
  loadRunnable = null
611
910
  displayRunnable?.let(hostView::removeCallbacks)
@@ -614,7 +913,9 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
614
913
  fallbackRunnable?.let(hostView::removeCallbacks)
615
914
  fallbackRunnable = null
616
915
  clearCurrentTarget()
916
+ if (clearMemoryPreview) clearMemoryPreviewTarget()
617
917
  requestActive = false
918
+ isPreservingDisplayedImage = false
618
919
  hostView.skeletonRequested = false
619
920
  requestStartedAtMs = null
620
921
  resetImageTransition()
@@ -629,6 +930,15 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
629
930
  requestManager().clear(target)
630
931
  }
631
932
 
933
+ private fun clearMemoryPreviewTarget() {
934
+ val target = memoryPreviewTarget
935
+ memoryPreviewTarget = null
936
+ memoryPreviewFamily = null
937
+ scalesCenterMemoryPreview = false
938
+ applyContentFit()
939
+ if (target != null) requestManager().clear(target)
940
+ }
941
+
632
942
  private fun scheduleOnDisplay(requestGeneration: Long) {
633
943
  pendingDisplayGeneration = requestGeneration
634
944
  schedulePendingDisplayIfNeeded()
@@ -657,10 +967,22 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
657
967
  hostView.postOnAnimation(runnable)
658
968
  }
659
969
 
660
- private fun showLoading(requestIsActive: Boolean) {
970
+ private fun showLoading(
971
+ requestIsActive: Boolean,
972
+ preserveDisplayedImage: Boolean = false,
973
+ ) {
661
974
  displayState = DisplayState.LOADING
662
975
  requestActive = requestIsActive
663
976
  resetImageTransition()
977
+ if (preserveDisplayedImage && hostView.drawable != null) {
978
+ isPreservingDisplayedImage = true
979
+ hostView.skeletonRequested = false
980
+ hostView.drawStateSymbol = false
981
+ hostView.setBackgroundColor(Color.TRANSPARENT)
982
+ hostView.invalidate()
983
+ return
984
+ }
985
+ isPreservingDisplayedImage = false
664
986
  hostView.setImageDrawable(null)
665
987
  hostView.drawStateSymbol = false
666
988
  applyLoadingAppearance()
@@ -677,7 +999,9 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
677
999
  private fun showTerminalState(state: DisplayState) {
678
1000
  displayState = state
679
1001
  requestActive = false
1002
+ isPreservingDisplayedImage = false
680
1003
  resetImageTransition()
1004
+ clearMemoryPreviewTarget()
681
1005
  hostView.setImageDrawable(null)
682
1006
  hostView.skeletonRequested = false
683
1007
  val hidesTerminalState = loadingStrategy == OneKeyImageLoadingStrategy.NONE
@@ -746,7 +1070,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
746
1070
  private fun applyVariant() {
747
1071
  when (displayState) {
748
1072
  DisplayState.IMAGE -> Unit
749
- DisplayState.LOADING -> applyLoadingAppearance()
1073
+ DisplayState.LOADING -> if (!isPreservingDisplayedImage) applyLoadingAppearance()
750
1074
  DisplayState.ERROR, DisplayState.FALLBACK -> {
751
1075
  val hidesTerminalState = loadingStrategy == OneKeyImageLoadingStrategy.NONE
752
1076
  hostView.setBackgroundColor(
@@ -778,16 +1102,19 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
778
1102
  OneKeyImageVariant.AVATAR -> "●"
779
1103
  }
780
1104
 
781
- private fun applyLoadedImageTransition(cacheType: OneKeyImageCacheType) {
1105
+ private fun applyLoadedImageTransition(
1106
+ cacheType: OneKeyImageCacheType,
1107
+ replacingMemoryPreview: Boolean,
1108
+ ) {
782
1109
  resetImageTransition()
783
1110
  val startedAt = requestStartedAtMs ?: return
784
- if (
785
- cacheType == OneKeyImageCacheType.MEMORY ||
786
- SystemClock.uptimeMillis() - startedAt < FADE_DELAY_THRESHOLD_MS ||
787
- !animationsEnabled()
788
- ) {
789
- return
790
- }
1111
+ val shouldAnimate = shouldAnimateLoadedImageTransition(
1112
+ cacheType = cacheType,
1113
+ fadeDelayElapsed = SystemClock.uptimeMillis() - startedAt >= FADE_DELAY_THRESHOLD_MS,
1114
+ animationsEnabled = animationsEnabled(),
1115
+ replacingMemoryPreview = replacingMemoryPreview,
1116
+ )
1117
+ if (!shouldAnimate) return
791
1118
  hostView.alpha = 0f
792
1119
  hostView.animate()
793
1120
  .alpha(1f)
@@ -841,6 +1168,13 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
841
1168
  }
842
1169
 
843
1170
  private fun applyContentFit() {
1171
+ if (
1172
+ scalesCenterMemoryPreview &&
1173
+ (contentFit ?: OneKeyImageContentFit.COVER) == OneKeyImageContentFit.CENTER
1174
+ ) {
1175
+ hostView.scaleType = ImageView.ScaleType.FIT_CENTER
1176
+ return
1177
+ }
844
1178
  hostView.scaleType = when (contentFit ?: OneKeyImageContentFit.COVER) {
845
1179
  OneKeyImageContentFit.COVER -> ImageView.ScaleType.CENTER_CROP
846
1180
  OneKeyImageContentFit.CONTAIN -> ImageView.ScaleType.FIT_CENTER