@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.
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +76 -3
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +14 -1
- package/ios/OneKeyImage.swift +88 -19
- package/ios/OneKeyImageRequestContext.swift +34 -9
- package/ios/tests/OneKeyImageInfrastructureTests.swift +123 -19
- package/lib/module/index.js +5 -1
- package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
- package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
- package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
- package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
- package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
- package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
- package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
- package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
- package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
- package/lib/typescript/src/OneKeyImage.nitro.d.ts +8 -0
- package/lib/typescript/src/index.d.ts +1 -1
- package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
- package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
- package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
- package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
- package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
- package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
- package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
- package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
- package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
- package/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
- package/package.json +2 -2
- package/src/OneKeyImage.nitro.ts +8 -0
- 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(
|
|
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
|
package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt
CHANGED
|
@@ -26,7 +26,19 @@ class OneKeyImageRequestSignatureTest {
|
|
|
26
26
|
)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
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,
|
package/ios/OneKeyImage.swift
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
614
|
-
///
|
|
615
|
-
///
|
|
616
|
-
///
|
|
617
|
-
///
|
|
618
|
-
///
|
|
619
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
373
|
+
[try preloadKey(width: 48, height: 64)]
|
|
321
374
|
)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
contentFit: .cover
|
|
333
|
-
)
|
|
385
|
+
OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
|
|
386
|
+
bounds: .zero, resizeWidth: 0, resizeHeight: -1, scale: 3, contentFit: .cover
|
|
387
|
+
),
|
|
388
|
+
[]
|
|
334
389
|
)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
}
|