276 lines
8.8 KiB
Swift
276 lines
8.8 KiB
Swift
// Copyright 2022-present 650 Industries. All rights reserved.
|
|
|
|
import SDWebImage
|
|
import ExpoModulesCore
|
|
|
|
/**
|
|
An exception to throw when it its not possible to generate a blurhash for a given URL.
|
|
*/
|
|
public final class BlurhashGenerationException: Exception {
|
|
override public var reason: String {
|
|
"Unable to generate blurhash, make sure the image exists at the given URL"
|
|
}
|
|
}
|
|
|
|
func cacheTypeToString(_ cacheType: SDImageCacheType) -> String {
|
|
switch cacheType {
|
|
case .none:
|
|
return "none"
|
|
case .disk:
|
|
return "disk"
|
|
case .memory, .all:
|
|
// `all` doesn't make much sense, so we treat it as `memory`.
|
|
return "memory"
|
|
@unknown default:
|
|
log.error("Unhandled `SDImageCacheType` value: \(cacheType), returning `none` as fallback. Add the missing case as soon as possible.")
|
|
return "none"
|
|
}
|
|
}
|
|
|
|
func imageFormatToMediaType(_ format: SDImageFormat) -> String? {
|
|
switch format {
|
|
case .undefined:
|
|
return nil
|
|
case .JPEG:
|
|
return "image/jpeg"
|
|
case .PNG:
|
|
return "image/png"
|
|
case .GIF:
|
|
return "image/gif"
|
|
case .TIFF:
|
|
return "image/tiff"
|
|
case .webP:
|
|
return "image/webp"
|
|
case .HEIC:
|
|
return "image/heic"
|
|
case .HEIF:
|
|
return "image/heif"
|
|
case .PDF:
|
|
return "application/pdf"
|
|
case .SVG:
|
|
return "image/svg+xml"
|
|
default:
|
|
// On one hand we could remove this clause and always ensure that we have handled
|
|
// all supported formats (by erroring compilation otherwise).
|
|
// On the other hand, we do support overriding SDWebImage version,
|
|
// so we shouldn't fail to compile on SDWebImage versions with.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/**
|
|
Calculates the ideal size that fills in the container size while maintaining the source aspect ratio.
|
|
*/
|
|
func idealSize(contentPixelSize: CGSize, containerSize: CGSize, scale: Double = 1.0, contentFit: ContentFit) -> CGSize {
|
|
switch contentFit {
|
|
case .contain:
|
|
let aspectRatio = min(containerSize.width / contentPixelSize.width, containerSize.height / contentPixelSize.height)
|
|
return contentPixelSize * aspectRatio
|
|
case .cover:
|
|
let aspectRatio = max(containerSize.width / contentPixelSize.width, containerSize.height / contentPixelSize.height)
|
|
return contentPixelSize * aspectRatio
|
|
case .fill:
|
|
return containerSize
|
|
case .scaleDown:
|
|
if containerSize.width < contentPixelSize.width / scale || containerSize.height < contentPixelSize.height / scale {
|
|
// The container is smaller than the image — scale it down and behave like `contain`
|
|
let aspectRatio = min(containerSize.width / contentPixelSize.width, containerSize.height / contentPixelSize.height)
|
|
return contentPixelSize * aspectRatio
|
|
} else {
|
|
// The container is bigger than the image — don't scale it and behave like `none`
|
|
return contentPixelSize / scale
|
|
}
|
|
case .none:
|
|
return contentPixelSize / scale
|
|
}
|
|
}
|
|
|
|
/**
|
|
Returns a bool whether the image should be downscaled to the given size.
|
|
*/
|
|
func shouldDownscale(image: UIImage, toSize size: CGSize, scale: Double) -> Bool {
|
|
if size.width <= 0 || size.height <= 0 {
|
|
// View is invisible, so no reason to keep the image in memory.
|
|
// This already ensures that we won't be diving by zero in ratio calculations.
|
|
return true
|
|
}
|
|
if size.width.isInfinite || size.height.isInfinite {
|
|
// Keep the image unscaled for infinite sizes.
|
|
return false
|
|
}
|
|
let imageSize = image.size * image.scale
|
|
return imageSize.width > (size.width * scale) && imageSize.height > (size.height * scale)
|
|
}
|
|
|
|
/**
|
|
Resizes a static image to fit in the given size and scale.
|
|
*/
|
|
func resize(image: UIImage, toSize size: CGSize, scale: Double) -> UIImage {
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = scale
|
|
|
|
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
|
|
image.draw(in: CGRect(origin: .zero, size: size))
|
|
}
|
|
}
|
|
|
|
/**
|
|
The image source that fits best into the given size, that is the one with the closest number of pixels.
|
|
May be `nil` if there are no sources available or the size is zero.
|
|
*/
|
|
func getBestSource(from sources: [ImageSource]?, forSize size: CGSize, scale: Double = 1.0) -> ImageSource? {
|
|
guard let sources = sources, !sources.isEmpty else {
|
|
return nil
|
|
}
|
|
if size.width <= 0 || size.height <= 0 {
|
|
return nil
|
|
}
|
|
if sources.count == 1 {
|
|
return sources.first
|
|
}
|
|
var bestSource: ImageSource?
|
|
var bestFit = Double.infinity
|
|
let targetPixelCount = size.width * size.height * scale * scale
|
|
|
|
for source in sources {
|
|
let fit = abs(1 - (source.pixelCount / targetPixelCount))
|
|
|
|
if fit < bestFit {
|
|
bestSource = source
|
|
bestFit = fit
|
|
}
|
|
}
|
|
return bestSource
|
|
}
|
|
|
|
/**
|
|
Creates the cache key filter that returns the specific string.
|
|
*/
|
|
func createCacheKeyFilter(_ cacheKey: String?) -> SDWebImageCacheKeyFilter? {
|
|
guard let cacheKey = cacheKey else {
|
|
return nil
|
|
}
|
|
return SDWebImageCacheKeyFilter { _ in
|
|
return cacheKey
|
|
}
|
|
}
|
|
|
|
/**
|
|
Creates a default image context based on the source and the cache policy.
|
|
*/
|
|
func createSDWebImageContext(forSource source: ImageSource, cachePolicy: ImageCachePolicy = .disk, useAppleWebpCodec: Bool = true) -> SDWebImageContext {
|
|
var context = SDWebImageContext()
|
|
|
|
// Modify URL request to add headers.
|
|
if let headers = source.headers {
|
|
context[.downloadRequestModifier] = SDWebImageDownloaderRequestModifier(headers: headers)
|
|
}
|
|
|
|
// Allow for custom cache key. If not specified in the source, its uri is used as the key.
|
|
context[.cacheKeyFilter] = createCacheKeyFilter(source.cacheKey)
|
|
|
|
// Tell SDWebImage to use our own class for animated formats,
|
|
// which has better compatibility with the UIImage and fixes issues with the image duration.
|
|
context[.animatedImageClass] = AnimatedImage.self
|
|
|
|
// Passing useAppleWebpCodec into WebPCoder
|
|
context[.imageDecodeOptions] = [
|
|
imageCoderOptionUseAppleWebpCodec: useAppleWebpCodec
|
|
]
|
|
|
|
// Assets from the bundler have `scale` prop which needs to be passed to the context,
|
|
// otherwise they would be saved in cache with scale = 1.0 which may result in
|
|
// incorrectly rendered images for resize modes that don't scale (`center` and `repeat`).
|
|
context[.imageScaleFactor] = source.scale
|
|
|
|
let sdCacheType = cachePolicy.toSdCacheType().rawValue
|
|
context[.queryCacheType] = sdCacheType
|
|
context[.storeCacheType] = sdCacheType
|
|
|
|
if source.cacheOriginalImage {
|
|
context[.originalQueryCacheType] = sdCacheType
|
|
context[.originalStoreCacheType] = sdCacheType
|
|
} else {
|
|
context[.originalQueryCacheType] = SDImageCacheType.none.rawValue
|
|
context[.originalStoreCacheType] = SDImageCacheType.none.rawValue
|
|
}
|
|
|
|
// Some loaders (e.g. blurhash) may need access to the source.
|
|
context[ImageView.contextSourceKey] = source
|
|
|
|
return context
|
|
}
|
|
|
|
extension CGSize {
|
|
/**
|
|
Multiplies a size with a scalar.
|
|
*/
|
|
static func * (size: CGSize, scalar: Double) -> CGSize {
|
|
return CGSize(width: size.width * scalar, height: size.height * scalar)
|
|
}
|
|
|
|
/**
|
|
Divides a size with a scalar.
|
|
*/
|
|
static func / (size: CGSize, scalar: Double) -> CGSize {
|
|
return CGSize(width: size.width / scalar, height: size.height / scalar)
|
|
}
|
|
|
|
/**
|
|
Returns a new CGSize with width and height rounded to an integral value using the specified rounding rule.
|
|
*/
|
|
func rounded(_ rule: FloatingPointRoundingRule) -> CGSize {
|
|
return CGSize(width: width.rounded(rule), height: height.rounded(rule))
|
|
}
|
|
}
|
|
|
|
func makeNSError(description: String) -> NSError {
|
|
let userInfo = [NSLocalizedDescriptionKey: description]
|
|
return NSError(domain: "expo.modules.image", code: 0, userInfo: userInfo)
|
|
}
|
|
|
|
// MARK: - Async helpers
|
|
// TODO: Add helpers like these to the modules core eventually
|
|
|
|
/**
|
|
Asynchronously maps the given sequence (sequentially).
|
|
*/
|
|
func asyncMap<ItemsType: Sequence, ResultType>(
|
|
_ items: ItemsType,
|
|
_ transform: (ItemsType.Element) async throws -> ResultType
|
|
) async rethrows -> [ResultType] {
|
|
var values = [ResultType]()
|
|
|
|
for item in items {
|
|
try await values.append(transform(item))
|
|
}
|
|
return values
|
|
}
|
|
|
|
/**
|
|
Concurrently maps the given sequence.
|
|
*/
|
|
func concurrentMap<ItemsType: Sequence, ResultType: Sendable>(
|
|
_ items: ItemsType,
|
|
_ transform: @Sendable @escaping (ItemsType.Element) async throws -> ResultType
|
|
) async rethrows -> [ResultType] where ItemsType.Element: Sendable {
|
|
return try await withThrowingTaskGroup(of: (Int, ResultType).self) { group in
|
|
var results = Array<ResultType?>.init(repeating: nil, count: Array(items).count)
|
|
|
|
// Enumerate items to preserve the original order in the output.
|
|
for (index, item) in Array(items).enumerated() {
|
|
group.addTask { [item] in
|
|
let value = try await transform(item)
|
|
return (index, value)
|
|
}
|
|
}
|
|
|
|
while let (index, value) = try await group.next() {
|
|
results[index] = value
|
|
}
|
|
|
|
// Compact map to unwrap optionals, all positions should be filled.
|
|
return results.compactMap { $0 }
|
|
}
|
|
}
|