-
Notifications
You must be signed in to change notification settings - Fork 28
/
CoilImageSource.kt
183 lines (167 loc) · 6.45 KB
/
CoilImageSource.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
package me.saket.telephoto.zoomable.coil
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.size.Dimension
import coil.size.SizeResolver
import coil.transition.CrossfadeTransition
import com.google.accompanist.drawablepainter.DrawablePainter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import me.saket.telephoto.subsamplingimage.ImageBitmapOptions
import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
import me.saket.telephoto.zoomable.ZoomableImageSource
import me.saket.telephoto.zoomable.ZoomableImageSource.ResolveResult
import me.saket.telephoto.zoomable.internal.RememberWorker
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import coil.size.Size as CoilSize
internal class CoilImageSource(
private val model: Any?,
private val imageLoader: ImageLoader,
) : ZoomableImageSource {
@Composable
override fun resolve(canvasSize: Flow<Size>): ResolveResult {
val context = LocalContext.current
val resolver = remember(this) {
Resolver(
request = model as? ImageRequest
?: ImageRequest.Builder(context)
.data(model)
.build(),
imageLoader = imageLoader,
sizeResolver = { canvasSize.first().toCoilSize() }
)
}
return resolver.resolved
}
private fun Size.toCoilSize() = CoilSize(
width = if (width.isFinite()) Dimension(width.roundToInt()) else Dimension.Undefined,
height = if (height.isFinite()) Dimension(height.roundToInt()) else Dimension.Undefined
)
}
internal class Resolver(
private val request: ImageRequest,
private val imageLoader: ImageLoader,
private val sizeResolver: SizeResolver,
) : RememberWorker() {
internal var resolved: ResolveResult by mutableStateOf(
ResolveResult(delegate = null)
)
override suspend fun work() {
val result = imageLoader.execute(
request.newBuilder()
.size(request.defined.sizeResolver ?: sizeResolver)
// There's no easy way to be certain whether an image will require sub-sampling in
// advance so assume it'll be needed and force Coil to write this image to disk.
.diskCachePolicy(
when (request.diskCachePolicy) {
CachePolicy.ENABLED -> CachePolicy.ENABLED
CachePolicy.READ_ONLY -> CachePolicy.ENABLED
CachePolicy.WRITE_ONLY,
CachePolicy.DISABLED -> CachePolicy.WRITE_ONLY
}
)
// This will unfortunately replace any existing target, but it is also the only
// way to read placeholder images set using ImageRequest#placeholderMemoryCacheKey.
// Placeholder images should be small in size so sub-sampling isn't needed here.
.target(
onStart = {
resolved = resolved.copy(
placeholder = it?.asPainter()
)
}
)
.build()
)
val imageSource = result.toSubSamplingImageSource(imageLoader)
resolved = resolved.copy(
crossfadeDuration = result.crossfadeDuration(),
delegate = if (result is SuccessResult && imageSource != null) {
ZoomableImageSource.SubSamplingDelegate(
source = imageSource,
imageOptions = ImageBitmapOptions(from = (result.drawable as BitmapDrawable).bitmap)
)
} else {
ZoomableImageSource.PainterDelegate(
painter = result.drawable?.asPainter()
)
}
)
}
@OptIn(ExperimentalCoilApi::class)
private fun ImageResult.toSubSamplingImageSource(imageLoader: ImageLoader): SubSamplingImageSource? {
val result = this
val requestData = result.request.data
val preview = (result.drawable as? BitmapDrawable)?.bitmap?.asImageBitmap()
if (result is SuccessResult && result.drawable is BitmapDrawable) {
// Prefer reading of images directly from files whenever possible because
// that is significantly faster than reading from their input streams.
val imageSource = when {
result.diskCacheKey != null -> {
val diskCache = imageLoader.diskCache!!
val cached = diskCache[result.diskCacheKey!!] ?: error("Coil returned a null image from disk cache")
SubSamplingImageSource.file(cached.data, preview)
}
result.dataSource.let { it == DataSource.DISK || it == DataSource.MEMORY_CACHE } -> when {
requestData is Uri -> SubSamplingImageSource.contentUri(requestData, preview)
requestData is String -> SubSamplingImageSource.contentUri(Uri.parse(requestData), preview)
result.request.context.isResourceId(requestData) -> SubSamplingImageSource.resource(requestData, preview)
else -> null
}
else -> null
}
if (imageSource != null) {
return imageSource
}
}
return null
}
private fun ImageResult.crossfadeDuration(): Duration {
val transitionFactory = request.transitionFactory
return if (this is SuccessResult && transitionFactory is CrossfadeTransition.Factory) {
// I'm intentionally not using factory.create() because it optimizes crossfade duration
// to zero if the image was fetched from memory cache. SubSamplingImage will only read
// bitmaps from the disk so there will always be some delay in showing the image.
transitionFactory.durationMillis.milliseconds
} else {
Duration.ZERO
}
}
}
private fun Drawable.asPainter(): Painter {
return DrawablePainter(mutate())
}
@OptIn(ExperimentalContracts::class)
private fun Context.isResourceId(data: Any): Boolean {
contract {
returns(true) implies (data is Int)
}
if (data is Int) {
runCatching {
resources.getResourceEntryName(data)
return true
}
}
return false
}