update: latest source changes
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
// Copyright 2024-present 650 Industries. All rights reserved.
|
||||
|
||||
import SDWebImage
|
||||
|
||||
/**
|
||||
Custom `SDAnimatedImage` that fixes issues with `images` and `duration` not being available.
|
||||
*/
|
||||
final class AnimatedImage: SDAnimatedImage {
|
||||
var frames: [SDImageFrame]?
|
||||
|
||||
// MARK: - UIImage
|
||||
|
||||
override var images: [UIImage]? {
|
||||
preloadAllFrames()
|
||||
return frames?.map({ $0.image })
|
||||
}
|
||||
|
||||
override var duration: TimeInterval {
|
||||
preloadAllFrames()
|
||||
return frames?.reduce(0, { $0 + $1.duration }) ?? 0.0
|
||||
}
|
||||
|
||||
// MARK: - SDAnimatedImage
|
||||
|
||||
override func preloadAllFrames() {
|
||||
if frames != nil {
|
||||
return
|
||||
}
|
||||
frames = [UInt](0..<animatedImageFrameCount).compactMap { index in
|
||||
guard let image = animatedImageFrame(at: index) else {
|
||||
return nil
|
||||
}
|
||||
let duration = animatedImageDuration(at: index)
|
||||
return SDImageFrame(image: image, duration: duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
// The blurhash algorithm was entirely created by Wolt Enterprises.
|
||||
// This implementation was inspired by:
|
||||
// - https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashDecode.swift
|
||||
// - https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashEncode.swift
|
||||
// See https://blurha.sh for more details about the blurhash.
|
||||
|
||||
import UIKit
|
||||
|
||||
// swiftlint:disable force_unwrapping
|
||||
|
||||
internal func image(fromBlurhash blurhash: String, size: CGSize, punch: Float = 1.0) -> CGImage? {
|
||||
guard blurhash.count >= 6 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sizeFlag = decode83(String(blurhash[0]))
|
||||
let numY = (sizeFlag / 9) + 1
|
||||
let numX = (sizeFlag % 9) + 1
|
||||
|
||||
let quantisedMaximumValue = decode83(String(blurhash[1]))
|
||||
let maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
|
||||
guard blurhash.count == 4 + 2 * numX * numY else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let colors: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
|
||||
if i == 0 {
|
||||
let value = decode83(String(blurhash[2 ..< 6]))
|
||||
return decodeDC(value)
|
||||
} else {
|
||||
let value = decode83(String(blurhash[4 + i * 2 ..< 4 + i * 2 + 2]))
|
||||
return decodeAC(value, maximumValue: maximumValue * punch)
|
||||
}
|
||||
}
|
||||
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
let bytesPerRow = width * 3
|
||||
|
||||
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
CFDataSetLength(data, bytesPerRow * height)
|
||||
|
||||
guard let pixels = CFDataGetMutableBytePtr(data) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for y in 0 ..< height {
|
||||
for x in 0 ..< width {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
for j in 0 ..< numY {
|
||||
for i in 0 ..< numX {
|
||||
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
|
||||
let color = colors[i + j * numX]
|
||||
r += color.0 * basis
|
||||
g += color.1 * basis
|
||||
b += color.2 * basis
|
||||
}
|
||||
}
|
||||
|
||||
let intR = UInt8(linearTosRGB(r))
|
||||
let intG = UInt8(linearTosRGB(g))
|
||||
let intB = UInt8(linearTosRGB(b))
|
||||
|
||||
pixels[3 * x + 0 + y * bytesPerRow] = intR
|
||||
pixels[3 * x + 1 + y * bytesPerRow] = intG
|
||||
pixels[3 * x + 2 + y * bytesPerRow] = intB
|
||||
}
|
||||
}
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||
|
||||
guard let provider = CGDataProvider(data: data), let cgImage = CGImage(
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 24,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: bitmapInfo,
|
||||
provider: provider,
|
||||
decode: nil,
|
||||
shouldInterpolate: true,
|
||||
intent: .defaultIntent
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cgImage
|
||||
}
|
||||
|
||||
internal func blurhash(fromImage image: UIImage, numberOfComponents components: (Int, Int)) -> String? {
|
||||
let size = image.size
|
||||
let scale = image.scale
|
||||
|
||||
let pixelWidth = Int(round(size.width * scale))
|
||||
let pixelHeight = Int(round(size.height * scale))
|
||||
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: pixelWidth * 4,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)!
|
||||
context.scaleBy(x: scale, y: -scale)
|
||||
context.translateBy(x: 0, y: -size.height)
|
||||
|
||||
UIGraphicsPushContext(context)
|
||||
image.draw(at: .zero)
|
||||
UIGraphicsPopContext()
|
||||
|
||||
guard let cgImage = context.makeImage(),
|
||||
let dataProvider = cgImage.dataProvider,
|
||||
let data = dataProvider.data,
|
||||
let pixels = CFDataGetBytePtr(data) else {
|
||||
assertionFailure("Unexpected error!")
|
||||
return nil
|
||||
}
|
||||
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let bytesPerRow = cgImage.bytesPerRow
|
||||
|
||||
var factors: [(Float, Float, Float)] = []
|
||||
for y in 0 ..< components.1 {
|
||||
for x in 0 ..< components.0 {
|
||||
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
|
||||
let factor = multiplyBasisFunction(
|
||||
pixels: pixels,
|
||||
width: width,
|
||||
height: height,
|
||||
bytesPerRow: bytesPerRow,
|
||||
bytesPerPixel: cgImage.bitsPerPixel / 8,
|
||||
pixelOffset: 0
|
||||
) {
|
||||
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
|
||||
}
|
||||
factors.append(factor)
|
||||
}
|
||||
}
|
||||
|
||||
let dc = factors.first!
|
||||
let ac = factors.dropFirst()
|
||||
|
||||
var hash = ""
|
||||
|
||||
let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
|
||||
hash += encode83(sizeFlag, length: 1)
|
||||
|
||||
let maximumValue: Float
|
||||
if !ac.isEmpty {
|
||||
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
|
||||
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
|
||||
maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
hash += encode83(quantisedMaximumValue, length: 1)
|
||||
} else {
|
||||
maximumValue = 1
|
||||
hash += encode83(0, length: 1)
|
||||
}
|
||||
|
||||
hash += encode83(encodeDC(dc), length: 4)
|
||||
|
||||
for factor in ac {
|
||||
hash += encode83(encodeAC(factor, maximumValue: maximumValue), length: 2)
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
internal func isBlurhash(_ str: String) -> Bool {
|
||||
return str.allSatisfy { char in decodeCharacters[char] != nil }
|
||||
}
|
||||
|
||||
// MARK: - Encode
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
private func multiplyBasisFunction(
|
||||
pixels: UnsafePointer<UInt8>,
|
||||
width: Int,
|
||||
height: Int,
|
||||
bytesPerRow: Int,
|
||||
bytesPerPixel: Int,
|
||||
pixelOffset: Int,
|
||||
basisFunction: (Float, Float) -> Float
|
||||
) -> (Float, Float, Float) {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
|
||||
|
||||
for x in 0 ..< width {
|
||||
for y in 0 ..< height {
|
||||
let basis = basisFunction(Float(x), Float(y))
|
||||
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
|
||||
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
|
||||
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
|
||||
}
|
||||
}
|
||||
|
||||
let scale = 1 / Float(width * height)
|
||||
|
||||
return (r * scale, g * scale, b * scale)
|
||||
}
|
||||
|
||||
private let encodeCharacters: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~")
|
||||
|
||||
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
|
||||
let roundedR = linearTosRGB(value.0)
|
||||
let roundedG = linearTosRGB(value.1)
|
||||
let roundedB = linearTosRGB(value.2)
|
||||
return (roundedR << 16) + (roundedG << 8) + roundedB
|
||||
}
|
||||
|
||||
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
|
||||
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
|
||||
return quantR * 19 * 19 + quantG * 19 + quantB
|
||||
}
|
||||
|
||||
private func encode83(_ value: Int, length: Int) -> String {
|
||||
var result = ""
|
||||
for i in 1 ... length {
|
||||
let digit = (value / pow(83, length - i)) % 83
|
||||
result += String(encodeCharacters[Int(digit)])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Decode
|
||||
|
||||
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
|
||||
let intR = value >> 16
|
||||
let intG = (value >> 8) & 255
|
||||
let intB = value & 255
|
||||
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
|
||||
}
|
||||
|
||||
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
|
||||
let quantR = value / (19 * 19)
|
||||
let quantG = (value / 19) % 19
|
||||
let quantB = value % 19
|
||||
|
||||
let rgb = (
|
||||
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
|
||||
)
|
||||
|
||||
return rgb
|
||||
}
|
||||
|
||||
private let decodeCharacters: [Character: Int] = {
|
||||
var dict: [Character: Int] = [:]
|
||||
for (index, character) in encodeCharacters.enumerated() {
|
||||
dict[character] = index
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
|
||||
private func decode83(_ str: String) -> Int {
|
||||
var value: Int = 0
|
||||
for character in str {
|
||||
if let digit = decodeCharacters[character] {
|
||||
value = value * 83 + digit
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||
return copysign(pow(abs(value), exp), value)
|
||||
}
|
||||
|
||||
private func linearTosRGB(_ value: Float) -> Int {
|
||||
let v = max(0, min(1, value))
|
||||
if v <= 0.0031308 {
|
||||
return Int(v * 12.92 * 255 + 0.5)
|
||||
} else {
|
||||
return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||
let v = Float(Int64(value)) / 255
|
||||
if v <= 0.04045 {
|
||||
return v / 12.92
|
||||
} else {
|
||||
return pow((v + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
}
|
||||
|
||||
private func pow(_ base: Int, _ exponent: Int) -> Int {
|
||||
return (0 ..< exponent).reduce(1) { value, _ in value * base }
|
||||
}
|
||||
|
||||
private extension String {
|
||||
subscript (offset: Int) -> Character {
|
||||
return self[index(startIndex, offsetBy: offset)]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableClosedRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start...end]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start..<end]
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
|
||||
class BlurhashLoader: NSObject, SDImageLoader {
|
||||
// MARK: - SDImageLoader
|
||||
|
||||
func canRequestImage(for url: URL?) -> Bool {
|
||||
return url?.scheme == "blurhash"
|
||||
}
|
||||
|
||||
func requestImage(
|
||||
with url: URL?,
|
||||
options: SDWebImageOptions = [],
|
||||
context: [SDWebImageContextOption: Any]?,
|
||||
progress progressBlock: SDImageLoaderProgressBlock?,
|
||||
completed completedBlock: SDImageLoaderCompletedBlock? = nil
|
||||
) -> SDWebImageOperation? {
|
||||
guard let url = url else {
|
||||
let error = makeNSError(description: "URL provided to BlurhashLoader is missing")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return nil
|
||||
}
|
||||
guard let source = context?[ImageView.contextSourceKey] as? ImageSource else {
|
||||
let error = makeNSError(description: "Image source was not provided to the context")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The URI looks like this: blurhash:/WgF}G?az0fs.x[jat7xFRjNHt6s.4;oe-:RkVtkCi^Nbo|xZRjWB
|
||||
// Which means that the `pathComponents[0]` is `/` and we need to skip it to get the hash.
|
||||
let blurhash = url.pathComponents[1]
|
||||
let size = CGSize(width: source.width, height: source.height)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if let image = image(fromBlurhash: blurhash, size: size) {
|
||||
DispatchQueue.main.async {
|
||||
completedBlock?(UIImage(cgImage: image), nil, nil, true)
|
||||
}
|
||||
} else {
|
||||
let error = makeNSError(description: "Unable to generate an image from the given blurhash")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldBlockFailedURL(with url: URL, error: Error) -> Bool {
|
||||
// If the algorithm failed to generate an image from the url,
|
||||
// it's not possible that next time it will work :)
|
||||
return true
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
/**
|
||||
Describes how the image should be resized to fit its container.
|
||||
- Note: It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
|
||||
*/
|
||||
enum ContentFit: String, Enumerable {
|
||||
/**
|
||||
The image is scaled to maintain its aspect ratio while fitting within the container's box.
|
||||
The entire image is made to fill the box, while preserving its aspect ratio,
|
||||
so the image will be "letterboxed" if its aspect ratio does not match the aspect ratio of the box.
|
||||
*/
|
||||
case contain
|
||||
|
||||
/**
|
||||
The image is sized to maintain its aspect ratio while filling the element's entire content box.
|
||||
If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
|
||||
*/
|
||||
case cover
|
||||
|
||||
/**
|
||||
The image is sized to fill the element's content box. The entire object will completely fill the box.
|
||||
If the image's aspect ratio does not match the aspect ratio of its box, then the image will be stretched to fit.
|
||||
*/
|
||||
case fill
|
||||
|
||||
/**
|
||||
The image is not resized and is centered by default.
|
||||
When specified, the exact position can be controlled with `ContentPosition`.
|
||||
*/
|
||||
case none
|
||||
|
||||
/**
|
||||
The image is sized as if `none` or `contain` were specified,
|
||||
whichever would result in a smaller concrete image size.
|
||||
*/
|
||||
case scaleDown = "scale-down"
|
||||
|
||||
/**
|
||||
`ContentFit` cases can be directly translated to the native `UIView.ContentMode`
|
||||
except `scaleDown` that needs to be handled differently at the later step of rendering.
|
||||
*/
|
||||
func toContentMode() -> UIView.ContentMode {
|
||||
switch self {
|
||||
case .contain:
|
||||
return .scaleAspectFit
|
||||
case .cover:
|
||||
return .scaleAspectFill
|
||||
case .fill:
|
||||
return .scaleToFill
|
||||
case .none, .scaleDown:
|
||||
return .center
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
/**
|
||||
Represents a position value that might be either `Double` or `String`.
|
||||
*/
|
||||
typealias ContentPositionValue = Either<Double, String>
|
||||
|
||||
/**
|
||||
Specifies the alignment of the image within the container's box.
|
||||
- Note: Its intention is to behave like the CSS [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) property.
|
||||
*/
|
||||
struct ContentPosition: Record {
|
||||
static let center = Self()
|
||||
|
||||
@Field
|
||||
var top: ContentPositionValue?
|
||||
|
||||
@Field
|
||||
var bottom: ContentPositionValue?
|
||||
|
||||
@Field
|
||||
var right: ContentPositionValue?
|
||||
|
||||
@Field
|
||||
var left: ContentPositionValue?
|
||||
|
||||
/**
|
||||
Returns a horizontal content offset based on the `left` or `right` field.
|
||||
*/
|
||||
func offsetX(contentWidth: Double, containerWidth: Double) -> Double {
|
||||
let diff = containerWidth - contentWidth
|
||||
|
||||
if let leftDistance = distance(from: left) {
|
||||
return -diff / 2 + leftDistance
|
||||
}
|
||||
if let rightDistance = distance(from: right) {
|
||||
return diff / 2 - rightDistance
|
||||
}
|
||||
if let factor = factor(from: left) {
|
||||
return -diff / 2 + diff * factor
|
||||
}
|
||||
if let factor = factor(from: right) {
|
||||
return diff / 2 - diff * factor
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a vertical content offset based on the `top` or `bottom` field.
|
||||
*/
|
||||
func offsetY(contentHeight: Double, containerHeight: Double) -> Double {
|
||||
let diff = containerHeight - contentHeight
|
||||
|
||||
if let topDistance = distance(from: top) {
|
||||
return -diff / 2 + topDistance
|
||||
}
|
||||
if let bottomDistance = distance(from: bottom) {
|
||||
return diff / 2 - bottomDistance
|
||||
}
|
||||
if let factor = factor(from: top) {
|
||||
return -diff / 2 + diff * factor
|
||||
}
|
||||
if let factor = factor(from: bottom) {
|
||||
return diff / 2 - diff * factor
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
A `CGPoint` with horizontal and vertical content offsets.
|
||||
*/
|
||||
func offset(contentSize: CGSize, containerSize: CGSize) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: offsetX(contentWidth: contentSize.width, containerWidth: containerSize.width),
|
||||
y: offsetY(contentHeight: contentSize.height, containerHeight: containerSize.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a static offset from the given position value or `nil` when it cannot be cast to a `Double`.
|
||||
*/
|
||||
private func distance(from value: ContentPositionValue?) -> Double? {
|
||||
if let value: Double = value?.get() {
|
||||
return value
|
||||
}
|
||||
if let value: String = value?.get() {
|
||||
return Double(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a factor from the percentage value from the given position.
|
||||
The value must be a string containing a number and `%` character, or equal to `"center"` which is an equivalent to `50%`.
|
||||
*/
|
||||
private func factor(from value: ContentPositionValue?) -> Double? {
|
||||
guard let value: String = value?.get() else {
|
||||
return nil
|
||||
}
|
||||
if value == "center" {
|
||||
return 0.5
|
||||
}
|
||||
guard value.contains("%"), let percentage = Double(value.replacingOccurrences(of: "%", with: "")) else {
|
||||
return nil
|
||||
}
|
||||
return percentage / 100
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoImage'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.description = package['description']
|
||||
s.license = package['license']
|
||||
s.author = package['author']
|
||||
s.homepage = package['homepage']
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.swift_version = '5.9'
|
||||
s.source = { git: 'https://github.com/expo/expo.git' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'SDWebImage', '~> 5.21.0'
|
||||
s.dependency 'SDWebImageAVIFCoder', '~> 0.11.0'
|
||||
s.dependency 'SDWebImageSVGCoder', '~> 1.7.0'
|
||||
s.dependency 'SDWebImageWebPCoder', '~> 0.14.6'
|
||||
s.dependency 'libavif/libdav1d'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,swift}"
|
||||
s.exclude_files = 'Tests/'
|
||||
|
||||
s.test_spec 'Tests' do |test_spec|
|
||||
test_spec.dependency 'ExpoModulesTestCore'
|
||||
|
||||
test_spec.source_files = 'Tests/**/*.{m,swift}'
|
||||
end
|
||||
end
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// Copyright 2024-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
|
||||
internal final class Image: SharedRef<UIImage> {
|
||||
override var nativeRefType: String {
|
||||
"image"
|
||||
}
|
||||
|
||||
var isAnimated: Bool {
|
||||
return !(ref.images?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
override func getAdditionalMemoryPressure() -> Int {
|
||||
guard let cgImage = ref.cgImage else {
|
||||
return 0
|
||||
}
|
||||
return cgImage.bytesPerRow * cgImage.height
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
|
||||
enum ImageCachePolicy: String, Enumerable {
|
||||
case none = "none"
|
||||
case disk = "disk"
|
||||
case memory = "memory"
|
||||
case memoryAndDisk = "memory-disk"
|
||||
|
||||
func toSdCacheType() -> SDImageCacheType {
|
||||
switch self {
|
||||
case .none:
|
||||
return .none
|
||||
case .disk:
|
||||
return .disk
|
||||
case .memory:
|
||||
return .memory
|
||||
case .memoryAndDisk:
|
||||
return .all
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
|
||||
enum ImageCacheType: String, Enumerable {
|
||||
case none
|
||||
case disk
|
||||
case memory
|
||||
|
||||
static func fromSdCacheType(_ sdImageCacheType: SDImageCacheType) -> ImageCacheType {
|
||||
switch sdImageCacheType {
|
||||
case .none:
|
||||
return .none
|
||||
case .disk, .all:
|
||||
return .disk
|
||||
case .memory:
|
||||
return .memory
|
||||
@unknown default:
|
||||
log.error("Unhandled `SDImageCacheType` value: \(sdImageCacheType), returning `none` as fallback. Add the missing case as soon as possible.")
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Copyright 2024-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
|
||||
internal struct ImageLoadOptions: Record {
|
||||
@Field var maxWidth: Int = .max
|
||||
@Field var maxHeight: Int = .max
|
||||
|
||||
func getMaxSize() -> CGSize {
|
||||
return CGSize(width: maxWidth, height: maxHeight)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright 2024-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
|
||||
internal final class ImageLoadTask: SharedObject {
|
||||
private let source: ImageSource
|
||||
private let maxSize: CGSize?
|
||||
private var task: Task<UIImage, any Error>?
|
||||
|
||||
init(_ source: ImageSource, maxSize: CGSize? = nil) {
|
||||
self.source = source
|
||||
self.maxSize = maxSize
|
||||
super.init()
|
||||
}
|
||||
|
||||
func load() async throws -> UIImage {
|
||||
let task = self.task ?? Task {
|
||||
return try await ImageLoader.shared.load(source, maxSize: maxSize)
|
||||
}
|
||||
self.task = task
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
func abort() {
|
||||
task?.cancel()
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright 2024-present 650 Industries. All rights reserved.
|
||||
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
|
||||
internal final class ImageLoader {
|
||||
static let shared = ImageLoader()
|
||||
|
||||
lazy var imageManager = SDWebImageManager(
|
||||
cache: SDImageCache.shared,
|
||||
loader: SDImageLoadersManager.shared
|
||||
)
|
||||
|
||||
func load(_ source: ImageSource, maxSize: CGSize? = nil) async throws -> UIImage {
|
||||
// This loader uses only the disk cache. We may want to give more control on this, but the memory cache
|
||||
// doesn't make much sense for shared refs as they're kept in memory as long as their JS objects.
|
||||
var context = createSDWebImageContext(forSource: source, cachePolicy: .disk)
|
||||
|
||||
if let maxSize {
|
||||
context[.imageThumbnailPixelSize] = maxSize
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
imageManager.loadImage(with: source.uri, context: context, progress: nil) { image, _, error, _, _, _ in
|
||||
if let image {
|
||||
continuation.resume(returning: image)
|
||||
} else {
|
||||
continuation.resume(throwing: ImageLoadingFailed().causedBy(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal final class ImageLoadingFailed: Exception {
|
||||
override var reason: String {
|
||||
"Failed to load an image"
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
import SDWebImage
|
||||
import SDWebImageAVIFCoder
|
||||
import SDWebImageSVGCoder
|
||||
|
||||
public final class ImageModule: Module {
|
||||
lazy var prefetcher = SDWebImagePrefetcher.shared
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoImage")
|
||||
|
||||
OnCreate {
|
||||
ImageModule.registerCoders()
|
||||
ImageModule.registerLoaders()
|
||||
}
|
||||
|
||||
View(ImageView.self) {
|
||||
Events(
|
||||
"onLoadStart",
|
||||
"onProgress",
|
||||
"onError",
|
||||
"onLoad",
|
||||
"onDisplay"
|
||||
)
|
||||
|
||||
Prop("source") { (view: ImageView, sources: Either<[ImageSource], SharedRef<UIImage>>?) in
|
||||
if let imageRef: SharedRef<UIImage> = sources?.get() {
|
||||
// Unset an array of traditional sources and just render the image ref right away.
|
||||
view.sources = nil
|
||||
view.renderSourceImage(imageRef.ref)
|
||||
} else {
|
||||
// Update an array of sources. Image will start loading once the all props are updated.
|
||||
view.sources = sources?.get()
|
||||
view.sourceImage = nil
|
||||
}
|
||||
}
|
||||
|
||||
Prop("placeholder") { (view, placeholders: [ImageSource]?) in
|
||||
view.placeholderSources = placeholders ?? []
|
||||
}
|
||||
|
||||
Prop("contentFit") { (view, contentFit: ContentFit?) in
|
||||
view.contentFit = contentFit ?? .cover
|
||||
}
|
||||
|
||||
Prop("placeholderContentFit") { (view, placeholderContentFit: ContentFit?) in
|
||||
view.placeholderContentFit = placeholderContentFit ?? .scaleDown
|
||||
}
|
||||
|
||||
Prop("contentPosition") { (view, contentPosition: ContentPosition?) in
|
||||
view.contentPosition = contentPosition ?? .center
|
||||
}
|
||||
|
||||
Prop("transition") { (view, transition: ImageTransition?) in
|
||||
view.transition = transition
|
||||
}
|
||||
|
||||
Prop("blurRadius") { (view, blurRadius: Double?) in
|
||||
let radius = blurRadius ?? .zero
|
||||
// the implementation uses Apple's CIGaussianBlur internally
|
||||
// we divide the radius to achieve more consistent cross-platform appearance
|
||||
// the value was found experimentally
|
||||
view.blurRadius = radius / 2.0
|
||||
}
|
||||
|
||||
Prop("tintColor") { (view, tintColor: UIColor?) in
|
||||
view.imageTintColor = tintColor
|
||||
}
|
||||
|
||||
Prop("priority") { (view, priority: ImagePriority?) in
|
||||
view.loadingOptions.remove([.lowPriority, .highPriority])
|
||||
|
||||
if let priority = priority?.toSDWebImageOptions() {
|
||||
view.loadingOptions.insert(priority)
|
||||
}
|
||||
}
|
||||
|
||||
Prop("cachePolicy") { (view, cachePolicy: ImageCachePolicy?) in
|
||||
view.cachePolicy = cachePolicy ?? .disk
|
||||
}
|
||||
|
||||
Prop("enableLiveTextInteraction") { (view, enableLiveTextInteraction: Bool?) in
|
||||
#if !os(tvOS)
|
||||
view.enableLiveTextInteraction = enableLiveTextInteraction ?? false
|
||||
#endif
|
||||
}
|
||||
|
||||
Prop("accessible") { (view, accessible: Bool?) in
|
||||
view.sdImageView.isAccessibilityElement = accessible ?? false
|
||||
}
|
||||
|
||||
Prop("accessibilityLabel") { (view, label: String?) in
|
||||
view.sdImageView.accessibilityLabel = label
|
||||
}
|
||||
|
||||
Prop("recyclingKey") { (view, key: String?) in
|
||||
view.recyclingKey = key
|
||||
}
|
||||
|
||||
Prop("allowDownscaling") { (view, allowDownscaling: Bool?) in
|
||||
view.allowDownscaling = allowDownscaling ?? true
|
||||
}
|
||||
|
||||
Prop("autoplay") { (view, autoplay: Bool?) in
|
||||
view.autoplay = autoplay ?? true
|
||||
}
|
||||
|
||||
Prop("useAppleWebpCodec", true) { (view, useAppleWebpCodec: Bool) in
|
||||
view.useAppleWebpCodec = useAppleWebpCodec
|
||||
}
|
||||
|
||||
Prop("enforceEarlyResizing", false) { (view, enforceEarlyResizing: Bool) in
|
||||
view.enforceEarlyResizing = enforceEarlyResizing
|
||||
}
|
||||
|
||||
AsyncFunction("startAnimating") { (view: ImageView) in
|
||||
view.sdImageView.startAnimating()
|
||||
}
|
||||
|
||||
AsyncFunction("stopAnimating") { (view: ImageView) in
|
||||
view.sdImageView.stopAnimating()
|
||||
}
|
||||
|
||||
AsyncFunction("lockResourceAsync") { (view: ImageView) in
|
||||
view.lockResource = true
|
||||
}
|
||||
|
||||
AsyncFunction("unlockResourceAsync") { (view: ImageView) in
|
||||
view.lockResource = false
|
||||
}
|
||||
|
||||
AsyncFunction("reloadAsync") { (view: ImageView) in
|
||||
view.reload(force: true)
|
||||
}
|
||||
|
||||
OnViewDidUpdateProps { view in
|
||||
view.reload()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("prefetch") { (urls: [URL], cachePolicy: ImageCachePolicy, headersMap: [String: String]?, promise: Promise) in
|
||||
var context = SDWebImageContext()
|
||||
let sdCacheType = cachePolicy.toSdCacheType().rawValue
|
||||
context[.queryCacheType] = SDImageCacheType.none.rawValue
|
||||
context[.storeCacheType] = SDImageCacheType.none.rawValue
|
||||
context[.originalQueryCacheType] = sdCacheType
|
||||
context[.originalStoreCacheType] = sdCacheType
|
||||
|
||||
var imagesLoaded = 0
|
||||
var failed = false
|
||||
|
||||
if headersMap != nil {
|
||||
context[.downloadRequestModifier] = SDWebImageDownloaderRequestModifier(headers: headersMap)
|
||||
}
|
||||
|
||||
urls.forEach { url in
|
||||
SDWebImagePrefetcher.shared.prefetchURLs([url], context: context, progress: nil, completed: { _, skipped in
|
||||
if skipped > 0 && !failed {
|
||||
failed = true
|
||||
promise.resolve(false)
|
||||
} else {
|
||||
imagesLoaded = imagesLoaded + 1
|
||||
if imagesLoaded == urls.count {
|
||||
promise.resolve(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("generateBlurhashAsync") { (source: Either<Image, URL>, numberOfComponents: CGSize, promise: Promise) in
|
||||
let parsedNumberOfComponents = (width: Int(numberOfComponents.width), height: Int(numberOfComponents.height))
|
||||
generatePlaceholder(source: source) { (image: UIImage) in
|
||||
if let blurhashString = blurhash(fromImage: image, numberOfComponents: parsedNumberOfComponents) {
|
||||
promise.resolve(blurhashString)
|
||||
} else {
|
||||
promise.reject(BlurhashGenerationException())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("generateThumbhashAsync") { (source: Either<Image, URL>, promise: Promise) in
|
||||
generatePlaceholder(source: source) { (image: UIImage) in
|
||||
let blurhashString = thumbHash(fromImage: image)
|
||||
promise.resolve(blurhashString.base64EncodedString())
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("clearMemoryCache") { () -> Bool in
|
||||
SDImageCache.shared.clearMemory()
|
||||
return true
|
||||
}
|
||||
|
||||
AsyncFunction("clearDiskCache") { (promise: Promise) in
|
||||
SDImageCache.shared.clearDisk {
|
||||
promise.resolve(true)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("getCachePathAsync") { (cacheKey: String, promise: Promise) in
|
||||
/*
|
||||
We need to check if the image exists in the cache first since `cachePath` will
|
||||
return a path regardless of whether or not the image exists.
|
||||
*/
|
||||
SDImageCache.shared.diskImageExists(withKey: cacheKey) { exists in
|
||||
if exists {
|
||||
let cachePath = SDImageCache.shared.cachePath(forKey: cacheKey)
|
||||
|
||||
promise.resolve(cachePath)
|
||||
} else {
|
||||
promise.resolve(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("loadAsync") { (source: ImageSource, options: ImageLoadOptions?) -> Image? in
|
||||
let image = try await ImageLoadTask(source, maxSize: options?.getMaxSize()).load()
|
||||
return Image(image)
|
||||
}
|
||||
|
||||
Class(Image.self) {
|
||||
Property("width", \.ref.size.width)
|
||||
Property("height", \.ref.size.height)
|
||||
Property("scale", \.ref.scale)
|
||||
Property("isAnimated", \.isAnimated)
|
||||
Property("mediaType") { image in
|
||||
return imageFormatToMediaType(image.ref.sd_imageFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generatePlaceholder(
|
||||
source: Either<Image, URL>,
|
||||
generator: @escaping (UIImage) -> Void
|
||||
) {
|
||||
if let image: Image = source.get() {
|
||||
generator(image.ref)
|
||||
} else if let url: URL = source.get() {
|
||||
let downloader = SDWebImageDownloader()
|
||||
downloader.downloadImage(with: url, progress: nil, completed: { image, _, _, _ in
|
||||
DispatchQueue.global().async {
|
||||
if let downloadedImage = image {
|
||||
generator(downloadedImage)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static func registerCoders() {
|
||||
SDImageCodersManager.shared.addCoder(WebPCoder.shared)
|
||||
SDImageCodersManager.shared.addCoder(SDImageAVIFCoder.shared)
|
||||
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
|
||||
SDImageCodersManager.shared.addCoder(SDImageHEICCoder.shared)
|
||||
}
|
||||
|
||||
static func registerLoaders() {
|
||||
SDImageLoadersManager.shared.addLoader(BlurhashLoader())
|
||||
SDImageLoadersManager.shared.addLoader(ThumbhashLoader())
|
||||
SDImageLoadersManager.shared.addLoader(PhotoLibraryAssetLoader())
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import ExpoModulesCore
|
||||
import SDWebImage
|
||||
|
||||
enum ImagePriority: String, Enumerable {
|
||||
case low
|
||||
case normal
|
||||
case high
|
||||
|
||||
/**
|
||||
Maps the priority to `SDWebImageOptions` which is a bitmask thus has only low and high priority options.
|
||||
*/
|
||||
func toSDWebImageOptions() -> SDWebImageOptions? {
|
||||
switch self {
|
||||
case .low:
|
||||
return .lowPriority
|
||||
case .high:
|
||||
return .highPriority
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
|
||||
struct ImageSource: Record {
|
||||
@Field
|
||||
var width: Double = 0.0
|
||||
|
||||
@Field
|
||||
var height: Double = 0.0
|
||||
|
||||
@Field
|
||||
var uri: URL? = nil
|
||||
|
||||
@Field
|
||||
var scale: Double = 1.0
|
||||
|
||||
@Field
|
||||
var headers: [String: String]?
|
||||
|
||||
@Field
|
||||
var cacheKey: String?
|
||||
|
||||
var pixelCount: Double {
|
||||
return width * height * scale * scale
|
||||
}
|
||||
|
||||
var isBlurhash: Bool {
|
||||
return uri?.scheme == "blurhash"
|
||||
}
|
||||
|
||||
var isThumbhash: Bool {
|
||||
return uri?.scheme == "thumbhash"
|
||||
}
|
||||
|
||||
var isPhotoLibraryAsset: Bool {
|
||||
return isPhotoLibraryAssetUrl(uri)
|
||||
}
|
||||
|
||||
var cacheOriginalImage: Bool {
|
||||
return !isPhotoLibraryAsset
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
|
||||
enum ImageTransitionTiming: String, Enumerable {
|
||||
case easeInOut = "ease-in-out"
|
||||
case easeIn = "ease-in"
|
||||
case easeOut = "ease-out"
|
||||
case linear = "linear"
|
||||
|
||||
func toAnimationOption() -> UIView.AnimationOptions {
|
||||
switch self {
|
||||
case .easeInOut:
|
||||
return .curveEaseInOut
|
||||
case .easeIn:
|
||||
return .curveEaseIn
|
||||
case .easeOut:
|
||||
return .curveEaseOut
|
||||
case .linear:
|
||||
return .curveLinear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ImageTransitionEffect: String, Enumerable {
|
||||
case crossDissolve = "cross-dissolve"
|
||||
case flipFromTop = "flip-from-top"
|
||||
case flipFromRight = "flip-from-right"
|
||||
case flipFromBottom = "flip-from-bottom"
|
||||
case flipFromLeft = "flip-from-left"
|
||||
case curlUp = "curl-up"
|
||||
case curlDown = "curl-down"
|
||||
|
||||
func toAnimationOption() -> UIView.AnimationOptions {
|
||||
switch self {
|
||||
case .crossDissolve:
|
||||
return .transitionCrossDissolve
|
||||
case .flipFromLeft:
|
||||
return .transitionFlipFromLeft
|
||||
case .flipFromRight:
|
||||
return .transitionFlipFromRight
|
||||
case .flipFromTop:
|
||||
return .transitionFlipFromTop
|
||||
case .flipFromBottom:
|
||||
return .transitionFlipFromBottom
|
||||
case .curlUp:
|
||||
return .transitionCurlUp
|
||||
case .curlDown:
|
||||
return .transitionCurlDown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageTransition: Record {
|
||||
@Field
|
||||
var duration: Double = 100
|
||||
|
||||
@Field
|
||||
var timing: ImageTransitionTiming = .easeInOut
|
||||
|
||||
@Field
|
||||
var effect: ImageTransitionEffect = .crossDissolve
|
||||
|
||||
func toAnimationOptions() -> UIView.AnimationOptions {
|
||||
return [timing.toAnimationOption(), effect.toAnimationOption()]
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
// 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>(
|
||||
_ items: ItemsType,
|
||||
_ transform: @escaping (ItemsType.Element) async throws -> ResultType
|
||||
) async rethrows -> [ResultType] {
|
||||
let tasks = items.map { item in
|
||||
Task {
|
||||
try await transform(item)
|
||||
}
|
||||
}
|
||||
return try await asyncMap(tasks) { task in
|
||||
try await task.value
|
||||
}
|
||||
}
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
#if !os(tvOS)
|
||||
import VisionKit
|
||||
#endif
|
||||
|
||||
typealias SDWebImageContext = [SDWebImageContextOption: Any]
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
public final class ImageView: ExpoView {
|
||||
static let contextSourceKey = SDWebImageContextOption(rawValue: "source")
|
||||
static let screenScaleKey = SDWebImageContextOption(rawValue: "screenScale")
|
||||
static let contentFitKey = SDWebImageContextOption(rawValue: "contentFit")
|
||||
static let frameSizeKey = SDWebImageContextOption(rawValue: "frameSize")
|
||||
|
||||
let sdImageView = SDAnimatedImageView(frame: .zero)
|
||||
|
||||
// Custom image manager doesn't use shared loaders managers by default,
|
||||
// so make sure it is provided here.
|
||||
let imageManager = SDWebImageManager(
|
||||
cache: SDImageCache.shared,
|
||||
loader: SDImageLoadersManager.shared
|
||||
)
|
||||
|
||||
var loadingOptions: SDWebImageOptions = [
|
||||
.retryFailed, // Don't blacklist URLs that failed downloading
|
||||
.handleCookies, // Handle cookies stored in the shared `HTTPCookieStore`
|
||||
// Images from cache are `AnimatedImage`s. BlurRadius is done via a SDImageBlurTransformer
|
||||
// so this flag needs to be enabled. Beware most transformers cannot manage animated images.
|
||||
.transformAnimatedImage
|
||||
]
|
||||
|
||||
/**
|
||||
An array of sources from which the view will asynchronously load one of them that fits best into the view bounds.
|
||||
*/
|
||||
var sources: [ImageSource]?
|
||||
|
||||
/**
|
||||
An image that has been loaded from one of the `sources` or set by the shared ref to an image.
|
||||
*/
|
||||
var sourceImage: UIImage?
|
||||
|
||||
var pendingOperation: SDWebImageCombinedOperation?
|
||||
|
||||
var contentFit: ContentFit = .cover
|
||||
|
||||
var contentPosition: ContentPosition = .center
|
||||
|
||||
var transition: ImageTransition?
|
||||
|
||||
var blurRadius: CGFloat = 0.0
|
||||
|
||||
var imageTintColor: UIColor?
|
||||
|
||||
var cachePolicy: ImageCachePolicy = .disk
|
||||
|
||||
var allowDownscaling: Bool = true
|
||||
|
||||
var lockResource: Bool = false
|
||||
|
||||
var enforceEarlyResizing: Bool = false
|
||||
|
||||
var recyclingKey: String? {
|
||||
didSet {
|
||||
if oldValue != nil && recyclingKey != oldValue {
|
||||
sdImageView.image = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var autoplay: Bool = true
|
||||
|
||||
var useAppleWebpCodec: Bool = true
|
||||
|
||||
/**
|
||||
The ideal image size that fills in the container size while maintaining the source aspect ratio.
|
||||
*/
|
||||
var imageIdealSize: CGSize = .zero
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
let onLoadStart = EventDispatcher()
|
||||
|
||||
let onProgress = EventDispatcher()
|
||||
|
||||
let onError = EventDispatcher()
|
||||
|
||||
let onLoad = EventDispatcher()
|
||||
|
||||
let onDisplay = EventDispatcher()
|
||||
|
||||
// MARK: - View
|
||||
|
||||
public override var bounds: CGRect {
|
||||
didSet {
|
||||
// Reload the image when the bounds size has changed and is not empty.
|
||||
if oldValue.size != bounds.size && bounds.size != .zero {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
|
||||
clipsToBounds = true
|
||||
sdImageView.contentMode = contentFit.toContentMode()
|
||||
sdImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
sdImageView.layer.masksToBounds = false
|
||||
|
||||
// Apply trilinear filtering to smooth out mis-sized images.
|
||||
sdImageView.layer.magnificationFilter = .trilinear
|
||||
sdImageView.layer.minificationFilter = .trilinear
|
||||
|
||||
addSubview(sdImageView)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Cancel pending requests when the view is deallocated.
|
||||
cancelPendingOperation()
|
||||
}
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
|
||||
// The mask layer we adjusted would be invaliated from `RCTViewComponentView.traitCollectionDidChange`.
|
||||
// After that we have to recalculate the mask layer in `applyContentPosition`.
|
||||
applyContentPosition(contentSize: imageIdealSize, containerSize: frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementation
|
||||
|
||||
func reload(force: Bool = false) {
|
||||
if lockResource && !force {
|
||||
return
|
||||
}
|
||||
if isViewEmpty {
|
||||
displayPlaceholderIfNecessary()
|
||||
}
|
||||
guard let source = bestSource else {
|
||||
displayPlaceholderIfNecessary()
|
||||
return
|
||||
}
|
||||
if sdImageView.image == nil {
|
||||
sdImageView.contentMode = contentFit.toContentMode()
|
||||
}
|
||||
var context = createSDWebImageContext(forSource: source, cachePolicy: cachePolicy, useAppleWebpCodec: useAppleWebpCodec)
|
||||
|
||||
// Cancel currently running load requests.
|
||||
cancelPendingOperation()
|
||||
|
||||
if blurRadius > 0 {
|
||||
context[.imageTransformer] = createTransformPipeline()
|
||||
}
|
||||
|
||||
// It seems that `UIImageView` can't tint some vector graphics. If the `tintColor` prop is specified,
|
||||
// we tell the SVG coder to decode to a bitmap instead. This will become useless when we switch to SVGNative coder.
|
||||
let shouldEarlyResize = imageTintColor != nil || enforceEarlyResizing || source.isPhotoLibraryAsset
|
||||
if shouldEarlyResize {
|
||||
context[.imagePreserveAspectRatio] = true
|
||||
context[.imageThumbnailPixelSize] = CGSize(
|
||||
width: sdImageView.bounds.size.width * screenScale,
|
||||
height: sdImageView.bounds.size.height * screenScale
|
||||
)
|
||||
}
|
||||
|
||||
// Some loaders (e.g. PhotoLibraryAssetLoader) may need to know the screen scale.
|
||||
context[ImageView.screenScaleKey] = screenScale
|
||||
context[ImageView.frameSizeKey] = frame.size
|
||||
context[ImageView.contentFitKey] = contentFit
|
||||
|
||||
// Do it here so we don't waste resources trying to fetch from a remote URL
|
||||
if maybeRenderLocalAsset(from: source) {
|
||||
return
|
||||
}
|
||||
|
||||
onLoadStart([:])
|
||||
|
||||
pendingOperation = imageManager.loadImage(
|
||||
with: source.uri,
|
||||
options: loadingOptions,
|
||||
context: context,
|
||||
progress: imageLoadProgress(_:_:_:),
|
||||
completed: imageLoadCompleted(_:_:_:_:_:_:)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
private func imageLoadProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {
|
||||
// Don't send the event when the expected size is unknown (it's usually -1 or 0 when called for the first time).
|
||||
if expectedSize <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Photos library requester emits the progress as a double `0...1` that we map to `0...100` int in `PhotosLoader`.
|
||||
// When that loader is used, we don't have any information about the sizes in bytes, so we only send the `progress` param.
|
||||
let isPhotoLibraryAsset = isPhotoLibraryAssetUrl(imageUrl)
|
||||
|
||||
onProgress([
|
||||
"loaded": isPhotoLibraryAsset ? nil : receivedSize,
|
||||
"total": isPhotoLibraryAsset ? nil : expectedSize,
|
||||
"progress": Double(receivedSize) / Double(expectedSize)
|
||||
])
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
private func imageLoadCompleted(
|
||||
_ image: UIImage?,
|
||||
_ data: Data?,
|
||||
_ error: Error?,
|
||||
_ cacheType: SDImageCacheType,
|
||||
_ finished: Bool,
|
||||
_ imageUrl: URL?
|
||||
) {
|
||||
if let error = error {
|
||||
let code = (error as NSError).code
|
||||
|
||||
// SDWebImage throws an error when loading operation is canceled (interrupted) by another load request.
|
||||
// We do want to ignore that one and wait for the new request to load.
|
||||
if code != SDWebImageError.cancelled.rawValue {
|
||||
onError(["error": error.localizedDescription])
|
||||
}
|
||||
return
|
||||
}
|
||||
guard finished else {
|
||||
log.debug("Loading the image has been canceled")
|
||||
return
|
||||
}
|
||||
|
||||
if let image {
|
||||
onLoad([
|
||||
"cacheType": cacheTypeToString(cacheType),
|
||||
"source": [
|
||||
"url": imageUrl?.absoluteString,
|
||||
"width": image.size.width,
|
||||
"height": image.size.height,
|
||||
"mediaType": imageFormatToMediaType(image.sd_imageFormat),
|
||||
"isAnimated": image.sd_isAnimated
|
||||
]
|
||||
])
|
||||
|
||||
let scale = window?.screen.scale ?? UIScreen.main.scale
|
||||
imageIdealSize = idealSize(
|
||||
contentPixelSize: image.size * image.scale,
|
||||
containerSize: frame.size,
|
||||
scale: scale,
|
||||
contentFit: contentFit
|
||||
).rounded(.up)
|
||||
|
||||
let image = processImage(image, idealSize: imageIdealSize, scale: scale)
|
||||
applyContentPosition(contentSize: imageIdealSize, containerSize: frame.size)
|
||||
renderSourceImage(image)
|
||||
} else {
|
||||
displayPlaceholderIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
private func maybeRenderLocalAsset(from source: ImageSource) -> Bool {
|
||||
let path: String? = {
|
||||
// .path() on iOS 16 would remove the leading slash, but it doesn't on tvOS 16 🙃
|
||||
// It also crashes with EXC_BREAKPOINT when parsing data:image uris
|
||||
// manually drop the leading slash below iOS 16
|
||||
if let path = source.uri?.path {
|
||||
return String(path.dropFirst())
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if let path, !path.isEmpty, let local = UIImage(named: path) {
|
||||
renderSourceImage(local)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
/**
|
||||
A list of sources that the placeholder can be loaded from.
|
||||
*/
|
||||
var placeholderSources: [ImageSource] = [] {
|
||||
didSet {
|
||||
loadPlaceholderIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
A placeholder image to use when the proper image is unset.
|
||||
*/
|
||||
var placeholderImage: UIImage?
|
||||
|
||||
/**
|
||||
Content fit for the placeholder. `scale-down` seems to be the best choice for spinners
|
||||
and that the placeholders are usually smaller than the proper image, but it doesn't
|
||||
apply to blurhash that by default could use the same fitting as the proper image.
|
||||
*/
|
||||
var placeholderContentFit: ContentFit = .scaleDown
|
||||
|
||||
/**
|
||||
Same as `bestSource`, but for placeholders.
|
||||
*/
|
||||
var bestPlaceholder: ImageSource? {
|
||||
return getBestSource(from: placeholderSources, forSize: bounds.size, scale: screenScale) ?? placeholderSources.first
|
||||
}
|
||||
|
||||
/**
|
||||
A bool value whether the placeholder can be displayed, i.e. nothing has been displayed yet or the sources are unset.
|
||||
*/
|
||||
var canDisplayPlaceholder: Bool {
|
||||
return isViewEmpty || (!hasAnySource && sourceImage == nil)
|
||||
}
|
||||
|
||||
/**
|
||||
Loads a placeholder from the best source provided in `placeholder` prop.
|
||||
A placeholder should be a local asset to have more time to show before the proper image is loaded,
|
||||
but remote assets are also supported – for the bundler and to cache them on the disk to load faster next time.
|
||||
- Note: Placeholders are not being resized nor transformed, so try to keep them small.
|
||||
*/
|
||||
func loadPlaceholderIfNecessary() {
|
||||
// Exit early if placeholder is not set or there is already an image attached to the view.
|
||||
// The placeholder is only used until the first image is loaded.
|
||||
guard canDisplayPlaceholder, let placeholder = bestPlaceholder else {
|
||||
return
|
||||
}
|
||||
|
||||
// Cache placeholders on the disk. Should we let the user choose whether
|
||||
// to cache them or apply the same policy as with the proper image?
|
||||
// Basically they are also cached in memory as the `placeholderImage` property,
|
||||
// so just `disk` policy sounds like a good idea.
|
||||
var context = createSDWebImageContext(forSource: placeholder, cachePolicy: .disk, useAppleWebpCodec: useAppleWebpCodec)
|
||||
|
||||
let isPlaceholderHash = placeholder.isBlurhash || placeholder.isThumbhash
|
||||
|
||||
imageManager.loadImage(with: placeholder.uri, context: context, progress: nil) { [weak self] placeholder, _, _, _, finished, _ in
|
||||
guard let self, let placeholder, finished else {
|
||||
return
|
||||
}
|
||||
self.placeholderImage = placeholder
|
||||
self.placeholderContentFit = isPlaceholderHash ? self.contentFit : self.placeholderContentFit
|
||||
self.displayPlaceholderIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Displays a placeholder if necessary – the placeholder can only be displayed when no image has been displayed yet or the sources are unset.
|
||||
*/
|
||||
private func displayPlaceholderIfNecessary() {
|
||||
guard canDisplayPlaceholder, let placeholder = placeholderImage else {
|
||||
return
|
||||
}
|
||||
setImage(placeholder, contentFit: placeholderContentFit, isPlaceholder: true)
|
||||
}
|
||||
|
||||
// MARK: - Processing
|
||||
|
||||
private func createTransformPipeline() -> SDImagePipelineTransformer? {
|
||||
let transformers: [SDImageTransformer] = [
|
||||
SDImageBlurTransformer(radius: blurRadius)
|
||||
]
|
||||
return SDImagePipelineTransformer(transformers: transformers)
|
||||
}
|
||||
|
||||
private func processImage(_ image: UIImage?, idealSize: CGSize, scale: Double) -> UIImage? {
|
||||
guard let image = image, !bounds.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
sdImageView.animationTransformer = nil
|
||||
// Downscale the image only when necessary
|
||||
if allowDownscaling && shouldDownscale(image: image, toSize: idealSize, scale: scale) {
|
||||
if image.sd_isAnimated {
|
||||
let size = idealSize * scale
|
||||
sdImageView.animationTransformer = SDImageResizingTransformer(size: size, scaleMode: .fill)
|
||||
return image
|
||||
}
|
||||
return resize(image: image, toSize: idealSize, scale: scale)
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
/**
|
||||
Moves the layer on which the image is rendered to respect the `contentPosition` prop.
|
||||
*/
|
||||
private func applyContentPosition(contentSize: CGSize, containerSize: CGSize) {
|
||||
let offset = contentPosition.offset(contentSize: contentSize, containerSize: containerSize)
|
||||
if sdImageView.layer.mask != nil {
|
||||
// In New Architecture mode, React Native adds a mask layer to image subviews.
|
||||
// When moving the layer frame, we must move the mask layer with a compensation value.
|
||||
// This prevents the layer from being cropped.
|
||||
// See https://github.com/expo/expo/issues/34201
|
||||
// and https://github.com/facebook/react-native/blob/c72d4c5ee97/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L1066-L1076
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
sdImageView.layer.frame.origin = offset
|
||||
sdImageView.layer.mask?.frame.origin = CGPoint(x: -offset.x, y: -offset.y)
|
||||
CATransaction.commit()
|
||||
} else {
|
||||
sdImageView.layer.frame.origin = offset
|
||||
}
|
||||
}
|
||||
|
||||
internal func renderSourceImage(_ image: UIImage?) {
|
||||
// Update the source image before it gets rendered or transitioned to.
|
||||
sourceImage = image
|
||||
|
||||
if let transition = transition, transition.duration > 0 {
|
||||
let options = transition.toAnimationOptions()
|
||||
let seconds = transition.duration / 1000
|
||||
|
||||
UIView.transition(with: sdImageView, duration: seconds, options: options) { [weak self] in
|
||||
if let self {
|
||||
self.setImage(image, contentFit: self.contentFit, isPlaceholder: false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setImage(image, contentFit: contentFit, isPlaceholder: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func setImage(_ image: UIImage?, contentFit: ContentFit, isPlaceholder: Bool) {
|
||||
sdImageView.contentMode = contentFit.toContentMode()
|
||||
|
||||
if isPlaceholder {
|
||||
sdImageView.autoPlayAnimatedImage = true
|
||||
} else {
|
||||
sdImageView.autoPlayAnimatedImage = autoplay
|
||||
}
|
||||
|
||||
if let imageTintColor, !isPlaceholder {
|
||||
sdImageView.tintColor = imageTintColor
|
||||
sdImageView.image = image?.withRenderingMode(.alwaysTemplate)
|
||||
} else {
|
||||
sdImageView.tintColor = nil
|
||||
sdImageView.image = image
|
||||
}
|
||||
|
||||
if !isPlaceholder {
|
||||
onDisplay()
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
if enableLiveTextInteraction {
|
||||
analyzeImage()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func cancelPendingOperation() {
|
||||
pendingOperation?.cancel()
|
||||
pendingOperation = nil
|
||||
}
|
||||
|
||||
/**
|
||||
A scale of the screen where the view is presented,
|
||||
or the main scale if the view is not mounted yet.
|
||||
*/
|
||||
var screenScale: Double {
|
||||
return window?.screen.scale as? Double ?? UIScreen.main.scale
|
||||
}
|
||||
|
||||
/**
|
||||
The image source that fits best into the view bounds.
|
||||
*/
|
||||
var bestSource: ImageSource? {
|
||||
return getBestSource(from: sources, forSize: bounds.size, scale: screenScale)
|
||||
}
|
||||
|
||||
/**
|
||||
A bool value whether the image view doesn't render any image.
|
||||
*/
|
||||
var isViewEmpty: Bool {
|
||||
return sdImageView.image == nil
|
||||
}
|
||||
|
||||
/**
|
||||
A bool value whether there is any source to load from.
|
||||
*/
|
||||
var hasAnySource: Bool {
|
||||
return sources?.isEmpty == false
|
||||
}
|
||||
|
||||
// MARK: - Live Text Interaction
|
||||
#if !os(tvOS)
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
static let imageAnalyzer = ImageAnalyzer.isSupported ? ImageAnalyzer() : nil
|
||||
|
||||
var enableLiveTextInteraction: Bool = false {
|
||||
didSet {
|
||||
guard #available(iOS 16.0, macCatalyst 17.0, *), oldValue != enableLiveTextInteraction, ImageAnalyzer.isSupported else {
|
||||
return
|
||||
}
|
||||
if enableLiveTextInteraction {
|
||||
let imageAnalysisInteraction = ImageAnalysisInteraction()
|
||||
sdImageView.addInteraction(imageAnalysisInteraction)
|
||||
} else if let interaction = findImageAnalysisInteraction() {
|
||||
sdImageView.removeInteraction(interaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func analyzeImage() {
|
||||
guard #available(iOS 16.0, macCatalyst 17.0, *), ImageAnalyzer.isSupported, let image = sdImageView.image else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
guard let imageAnalyzer = Self.imageAnalyzer, let imageAnalysisInteraction = findImageAnalysisInteraction() else {
|
||||
return
|
||||
}
|
||||
let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode])
|
||||
|
||||
do {
|
||||
let imageAnalysis = try await imageAnalyzer.analyze(image, configuration: configuration)
|
||||
|
||||
// Make sure the image haven't changed in the meantime.
|
||||
if image == sdImageView.image {
|
||||
imageAnalysisInteraction.analysis = imageAnalysis
|
||||
imageAnalysisInteraction.preferredInteractionTypes = .automatic
|
||||
}
|
||||
} catch {
|
||||
log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macCatalyst 17.0, *)
|
||||
private func findImageAnalysisInteraction() -> ImageAnalysisInteraction? {
|
||||
let interaction = sdImageView.interactions.first {
|
||||
return $0 is ImageAnalysisInteraction
|
||||
}
|
||||
return interaction as? ImageAnalysisInteraction
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import Photos
|
||||
import Dispatch
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
|
||||
/**
|
||||
A custom loader for assets from the Photo Library. It handles all urls with the `ph` scheme.
|
||||
*/
|
||||
final class PhotoLibraryAssetLoader: NSObject, SDImageLoader {
|
||||
// MARK: - SDImageLoader
|
||||
|
||||
func canRequestImage(for url: URL?) -> Bool {
|
||||
return isPhotoLibraryAssetUrl(url)
|
||||
}
|
||||
|
||||
func requestImage(
|
||||
with url: URL?,
|
||||
options: SDWebImageOptions = [],
|
||||
context: SDWebImageContext?,
|
||||
progress progressBlock: SDImageLoaderProgressBlock?,
|
||||
completed completedBlock: SDImageLoaderCompletedBlock? = nil
|
||||
) -> SDWebImageOperation? {
|
||||
guard isPhotoLibraryStatusAuthorized() else {
|
||||
let error = makeNSError(description: "Unauthorized access to the Photo Library")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return nil
|
||||
}
|
||||
let operation = PhotoLibraryAssetLoaderOperation()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
guard let url = url, let assetLocalIdentifier = assetLocalIdentifier(fromUrl: url) else {
|
||||
let error = makeNSError(description: "Unable to obtain the asset identifier from the url: '\(String(describing: url?.absoluteString))'")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return
|
||||
}
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetLocalIdentifier], options: .none).firstObject else {
|
||||
let error = makeNSError(description: "Asset with identifier '\(assetLocalIdentifier)' not found in the Photo Library")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return
|
||||
}
|
||||
operation.requestId = requestAsset(
|
||||
asset,
|
||||
url: url,
|
||||
context: context,
|
||||
progressBlock: progressBlock,
|
||||
completedBlock: completedBlock
|
||||
)
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
func shouldBlockFailedURL(with url: URL, error: Error) -> Bool {
|
||||
// The lack of permission is one of the reasons of failed request,
|
||||
// but in that single case we don't want to blacklist the url as
|
||||
// the permission might be granted later and then the retry should be possible.
|
||||
return isPhotoLibraryStatusAuthorized()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a bool value whether the given url references the Photo Library asset.
|
||||
*/
|
||||
internal func isPhotoLibraryAssetUrl(_ url: URL?) -> Bool {
|
||||
return url?.scheme == "ph"
|
||||
}
|
||||
|
||||
/**
|
||||
Returns the local identifier of the asset from the given `ph://` url.
|
||||
These urls have the form of "ph://26687849-33F9-4402-8EC0-A622CD011D70",
|
||||
where the asset local identifier is used as the host part.
|
||||
*/
|
||||
private func assetLocalIdentifier(fromUrl url: URL) -> String? {
|
||||
return url.host
|
||||
}
|
||||
|
||||
/**
|
||||
Checks whether the app is authorized to read the Photo Library.
|
||||
*/
|
||||
private func isPhotoLibraryStatusAuthorized() -> Bool {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
return status == .authorized || status == .limited
|
||||
}
|
||||
|
||||
/**
|
||||
Requests the image of the given asset object and returns the request identifier.
|
||||
*/
|
||||
private func requestAsset(
|
||||
_ asset: PHAsset,
|
||||
url: URL,
|
||||
context: SDWebImageContext?,
|
||||
progressBlock: SDImageLoaderProgressBlock?,
|
||||
completedBlock: SDImageLoaderCompletedBlock?
|
||||
) -> PHImageRequestID {
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = false
|
||||
options.version = .current
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.resizeMode = .fast
|
||||
options.normalizedCropRect = .zero
|
||||
options.isNetworkAccessAllowed = true
|
||||
|
||||
if let progressBlock = progressBlock {
|
||||
options.progressHandler = { progress, _, _, _ in
|
||||
// The `progress` is a double from 0.0 to 1.0, but the loader needs integers so we map it to 0...100 range
|
||||
let progressPercentage = Int((progress * 100.0).rounded())
|
||||
progressBlock(progressPercentage, 100, url)
|
||||
}
|
||||
}
|
||||
|
||||
var targetSize = PHImageManagerMaximumSize
|
||||
|
||||
// We compute the minimal size required to display the image to avoid having to downsample it later
|
||||
if let scale = context?[ImageView.screenScaleKey] as? Double,
|
||||
let containerSize = context?[ImageView.frameSizeKey] as? CGSize,
|
||||
let contentFit = context?[ImageView.contentFitKey] as? ContentFit {
|
||||
targetSize = idealSize(
|
||||
contentPixelSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight),
|
||||
containerSize: containerSize,
|
||||
scale: scale,
|
||||
contentFit: contentFit
|
||||
).rounded(.up) * scale
|
||||
}
|
||||
|
||||
return PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
contentMode: .aspectFit,
|
||||
options: options,
|
||||
resultHandler: { image, info in
|
||||
// This value can be `true` only when network access is allowed and the photo is stored in the iCloud.
|
||||
let isDegraded: Bool = info?[PHImageResultIsDegradedKey] as? Bool ?? false
|
||||
|
||||
completedBlock?(image, nil, nil, !isDegraded)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Loader operation specialized for the Photo Library by keeping the request identifier.
|
||||
*/
|
||||
private class PhotoLibraryAssetLoaderOperation: NSObject, SDWebImageOperation {
|
||||
var canceled: Bool = false
|
||||
var requestId: PHImageRequestID?
|
||||
|
||||
// MARK: - SDWebImageOperation
|
||||
|
||||
func cancel() {
|
||||
if let requestId = requestId {
|
||||
PHImageManager.default().cancelImageRequest(requestId)
|
||||
}
|
||||
canceled = true
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import ExpoModulesTestCore
|
||||
|
||||
@testable import ExpoModulesCore
|
||||
@testable import ExpoImage
|
||||
|
||||
class ImageResizingSpec: ExpoSpec {
|
||||
override class func spec() {
|
||||
describe("ideal size") {
|
||||
// For simplicity use the same container size for all tests
|
||||
let containerSize = CGSize(width: 150, height: 100)
|
||||
|
||||
context("content size is 300x200") {
|
||||
let contentSize = CGSize(width: 300, height: 200)
|
||||
let aspectRatio = contentSize.width / contentSize.height
|
||||
|
||||
it("contains") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .contain)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == containerSize.height // 100
|
||||
expect(size.width / size.height) == aspectRatio // 1.5
|
||||
}
|
||||
|
||||
it("covers") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .cover)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == containerSize.height // 100
|
||||
expect(size.width / size.height) == aspectRatio // 1.5
|
||||
}
|
||||
|
||||
it("fills") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .fill)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == containerSize.height // 100
|
||||
}
|
||||
|
||||
it("scales down") {
|
||||
// Behaves like 'contain' content fit
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .scaleDown)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == containerSize.height // 100
|
||||
expect(size.width / size.height) == aspectRatio // 1.5
|
||||
}
|
||||
|
||||
it("doesn't resize") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .none)
|
||||
expect(size.width) == contentSize.width // 300
|
||||
expect(size.height) == contentSize.height // 200
|
||||
}
|
||||
}
|
||||
|
||||
context("content size is 168x412") {
|
||||
let contentSize = CGSize(width: 168, height: 412)
|
||||
let aspectRatio = contentSize.width / contentSize.height
|
||||
|
||||
it("contains") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .contain)
|
||||
expect(size.width) == (expected: containerSize.height * aspectRatio, delta: 0.0001) // ~40.77
|
||||
expect(size.height) == containerSize.height // 100
|
||||
expect(size.width / size.height) == (expected: aspectRatio, delta: 0.0001) // ~0.40
|
||||
}
|
||||
|
||||
it("covers") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .cover)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == (expected: containerSize.width / aspectRatio, delta: 0.0001) // ~367.85
|
||||
expect(size.width / size.height) == aspectRatio // ~0.40
|
||||
}
|
||||
|
||||
it("fills") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .fill)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == containerSize.height // 100
|
||||
}
|
||||
|
||||
it("scales down") {
|
||||
// Behaves like 'contain' content fit
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .scaleDown)
|
||||
expect(size.width) == (expected: containerSize.height * aspectRatio, delta: 0.0001) // ~40.77
|
||||
expect(size.height) == containerSize.height // 100
|
||||
expect(size.width / size.height) == (expected: aspectRatio, delta: 0.0001) // ~0.40
|
||||
}
|
||||
|
||||
it("doesn't resize") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .none)
|
||||
expect(size.width) == contentSize.width // 168
|
||||
expect(size.height) == contentSize.height // 412
|
||||
}
|
||||
}
|
||||
|
||||
context("content size is 37x21") {
|
||||
let contentSize = CGSize(width: 37, height: 21)
|
||||
let aspectRatio = contentSize.width / contentSize.height
|
||||
|
||||
it("contains") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .contain)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == (expected: containerSize.width / aspectRatio, delta: 0.0001) // ~85.13
|
||||
expect(size.width / size.height) == (expected: aspectRatio, delta: 0.0001) // ~1.76
|
||||
}
|
||||
|
||||
it("covers") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .cover)
|
||||
expect(size.width) == containerSize.height * aspectRatio // ~176.19
|
||||
expect(size.height) == containerSize.height // 100
|
||||
expect(size.width / size.height) == aspectRatio // ~1.76
|
||||
}
|
||||
|
||||
it("fills") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .fill)
|
||||
expect(size.width) == containerSize.width // 150
|
||||
expect(size.height) == containerSize.height // 100
|
||||
}
|
||||
|
||||
it("scales down") {
|
||||
// Behaves like `none` content fit
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .scaleDown)
|
||||
expect(size.width) == contentSize.width // 37
|
||||
expect(size.height) == contentSize.height // 21
|
||||
expect(size.width / size.height) == aspectRatio // ~1.76
|
||||
}
|
||||
|
||||
it("doesn't resize") {
|
||||
let size = idealSize(contentPixelSize: contentSize, containerSize: containerSize, contentFit: .none)
|
||||
expect(size.width) == contentSize.width // 37
|
||||
expect(size.height) == contentSize.height // 21
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
import Foundation
|
||||
|
||||
// Blurhash implementation thanks to @evanw work
|
||||
// https://github.com/evanw/thumbhash
|
||||
|
||||
// NOTE: Swift has an exponential-time type checker and compiling very simple
|
||||
// expressions can easily take many seconds, especially when expressions involve
|
||||
// numeric type constructors.
|
||||
//
|
||||
// This file deliberately breaks compound expressions up into separate variables
|
||||
// to improve compile time even though this comes at the expense of readability.
|
||||
// This is a known workaround for this deficiency in the Swift compiler.
|
||||
//
|
||||
// The following command is helpful when debugging Swift compile time issues:
|
||||
//
|
||||
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
|
||||
//
|
||||
// These optimizations brought the compile time for this file from around 2.5
|
||||
// seconds to around 250ms (10x faster).
|
||||
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
|
||||
// really awful. Debug builds compile a very generic indexing iterator thing
|
||||
// that makes many nested calls for every iteration, which makes debug-build
|
||||
// performance crawl.
|
||||
//
|
||||
// This file deliberately avoids for-in loops that loop for more than a few
|
||||
// times to improve debug-build run time even though this comes at the expense
|
||||
// of readability. Similarly unsafe pointers are used instead of array getters
|
||||
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
|
||||
//
|
||||
// These optimizations brought the run time to encode and decode 10 ThumbHashes
|
||||
// in debug mode from 700ms to 70ms (10x faster).
|
||||
|
||||
// swiftlint:disable all
|
||||
func rgbaToThumbHash(w: Int, h: Int, rgba: Data) -> Data {
|
||||
// Encoding an image larger than 100x100 is slow with no benefit
|
||||
assert(w <= 100 && h <= 100)
|
||||
assert(rgba.count == w * h * 4)
|
||||
|
||||
// Determine the average color
|
||||
var avg_r: Float32 = 0
|
||||
var avg_g: Float32 = 0
|
||||
var avg_b: Float32 = 0
|
||||
var avg_a: Float32 = 0
|
||||
rgba.withUnsafeBytes { rgba in
|
||||
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
|
||||
let n = w * h
|
||||
var i = 0
|
||||
while i < n {
|
||||
let alpha = Float32(rgba[3]) / 255
|
||||
avg_r += alpha / 255 * Float32(rgba[0])
|
||||
avg_g += alpha / 255 * Float32(rgba[1])
|
||||
avg_b += alpha / 255 * Float32(rgba[2])
|
||||
avg_a += alpha
|
||||
rgba = rgba.advanced(by: 4)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
if avg_a > 0 {
|
||||
avg_r /= avg_a
|
||||
avg_g /= avg_a
|
||||
avg_b /= avg_a
|
||||
}
|
||||
|
||||
let hasAlpha = avg_a < Float32(w * h)
|
||||
let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha
|
||||
let imax_wh = max(w, h)
|
||||
let iwl_limit = l_limit * w
|
||||
let ihl_limit = l_limit * h
|
||||
let fmax_wh = Float32(imax_wh)
|
||||
let fwl_limit = Float32(iwl_limit)
|
||||
let fhl_limit = Float32(ihl_limit)
|
||||
let flx = round(fwl_limit / fmax_wh)
|
||||
let fly = round(fhl_limit / fmax_wh)
|
||||
var lx = Int(flx)
|
||||
var ly = Int(fly)
|
||||
lx = max(1, lx)
|
||||
ly = max(1, ly)
|
||||
var lpqa = [Float32](repeating: 0, count: w * h * 4)
|
||||
|
||||
// Convert the image from RGBA to LPQA (composite atop the average color)
|
||||
rgba.withUnsafeBytes { rgba in
|
||||
lpqa.withUnsafeMutableBytes { lpqa in
|
||||
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
|
||||
var lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count)
|
||||
let n = w * h
|
||||
var i = 0
|
||||
while i < n {
|
||||
let alpha = Float32(rgba[3]) / 255
|
||||
let r = avg_r * (1 - alpha) + alpha / 255 * Float32(rgba[0])
|
||||
let g = avg_g * (1 - alpha) + alpha / 255 * Float32(rgba[1])
|
||||
let b = avg_b * (1 - alpha) + alpha / 255 * Float32(rgba[2])
|
||||
lpqa[0] = (r + g + b) / 3
|
||||
lpqa[1] = (r + g) / 2 - b
|
||||
lpqa[2] = r - g
|
||||
lpqa[3] = alpha
|
||||
rgba = rgba.advanced(by: 4)
|
||||
lpqa = lpqa.advanced(by: 4)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
|
||||
let encodeChannel = { (channel: UnsafePointer<Float32>, nx: Int, ny: Int) -> (Float32, [Float32], Float32) in
|
||||
var dc: Float32 = 0
|
||||
var ac: [Float32] = []
|
||||
var scale: Float32 = 0
|
||||
var fx = [Float32](repeating: 0, count: w)
|
||||
fx.withUnsafeMutableBytes { fx in
|
||||
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
|
||||
var cy = 0
|
||||
while cy < ny {
|
||||
var cx = 0
|
||||
while cx * ny < nx * (ny - cy) {
|
||||
var ptr = channel
|
||||
var f: Float32 = 0
|
||||
var x = 0
|
||||
while x < w {
|
||||
let fw = Float32(w)
|
||||
let fxx = Float32(x)
|
||||
let fcx = Float32(cx)
|
||||
fx[x] = cos(Float32.pi / fw * fcx * (fxx + 0.5))
|
||||
x += 1
|
||||
}
|
||||
var y = 0
|
||||
while y < h {
|
||||
let fh = Float32(h)
|
||||
let fyy = Float32(y)
|
||||
let fcy = Float32(cy)
|
||||
let fy = cos(Float32.pi / fh * fcy * (fyy + 0.5))
|
||||
var x = 0
|
||||
while x < w {
|
||||
f += ptr.pointee * fx[x] * fy
|
||||
x += 1
|
||||
ptr = ptr.advanced(by: 4)
|
||||
}
|
||||
y += 1
|
||||
}
|
||||
f /= Float32(w * h)
|
||||
if cx > 0 || cy > 0 {
|
||||
ac.append(f)
|
||||
scale = max(scale, abs(f))
|
||||
} else {
|
||||
dc = f
|
||||
}
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
}
|
||||
if scale > 0 {
|
||||
let n = ac.count
|
||||
var i = 0
|
||||
while i < n {
|
||||
ac[i] = 0.5 + 0.5 / scale * ac[i]
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return (dc, ac, scale)
|
||||
}
|
||||
let (
|
||||
(l_dc, l_ac, l_scale),
|
||||
(p_dc, p_ac, p_scale),
|
||||
(q_dc, q_ac, q_scale),
|
||||
(a_dc, a_ac, a_scale)
|
||||
) = lpqa.withUnsafeBytes { lpqa in
|
||||
let lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count)
|
||||
return (
|
||||
encodeChannel(lpqa, max(3, lx), max(3, ly)),
|
||||
encodeChannel(lpqa.advanced(by: 1), 3, 3),
|
||||
encodeChannel(lpqa.advanced(by: 2), 3, 3),
|
||||
hasAlpha ? encodeChannel(lpqa.advanced(by: 3), 5, 5) : (1, [], 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Write the constants
|
||||
let isLandscape = w > h
|
||||
let fl_dc = round(63.0 * l_dc)
|
||||
let fp_dc = round(31.5 + 31.5 * p_dc)
|
||||
let fq_dc = round(31.5 + 31.5 * q_dc)
|
||||
let fl_scale = round(31.0 * l_scale)
|
||||
let il_dc = UInt32(fl_dc)
|
||||
let ip_dc = UInt32(fp_dc)
|
||||
let iq_dc = UInt32(fq_dc)
|
||||
let il_scale = UInt32(fl_scale)
|
||||
let ihasAlpha = UInt32(hasAlpha ? 1 : 0)
|
||||
let header24 = il_dc | (ip_dc << 6) | (iq_dc << 12) | (il_scale << 18) | (ihasAlpha << 23)
|
||||
let fp_scale = round(63.0 * p_scale)
|
||||
let fq_scale = round(63.0 * q_scale)
|
||||
let ilxy = UInt16(isLandscape ? ly : lx)
|
||||
let ip_scale = UInt16(fp_scale)
|
||||
let iq_scale = UInt16(fq_scale)
|
||||
let iisLandscape = UInt16(isLandscape ? 1 : 0)
|
||||
let header16 = ilxy | (ip_scale << 3) | (iq_scale << 9) | (iisLandscape << 15)
|
||||
var hash = Data(capacity: 25)
|
||||
hash.append(UInt8(header24 & 255))
|
||||
hash.append(UInt8((header24 >> 8) & 255))
|
||||
hash.append(UInt8(header24 >> 16))
|
||||
hash.append(UInt8(header16 & 255))
|
||||
hash.append(UInt8(header16 >> 8))
|
||||
var isOdd = false
|
||||
if hasAlpha {
|
||||
let fa_dc = round(15.0 * a_dc)
|
||||
let fa_scale = round(15.0 * a_scale)
|
||||
let ia_dc = UInt8(fa_dc)
|
||||
let ia_scale = UInt8(fa_scale)
|
||||
hash.append(ia_dc | (ia_scale << 4))
|
||||
}
|
||||
|
||||
// Write the varying factors
|
||||
for ac in [l_ac, p_ac, q_ac] {
|
||||
for f in ac {
|
||||
let f15 = round(15.0 * f)
|
||||
let i15 = UInt8(f15)
|
||||
if isOdd {
|
||||
hash[hash.count - 1] |= i15 << 4
|
||||
} else {
|
||||
hash.append(i15)
|
||||
}
|
||||
isOdd = !isOdd
|
||||
}
|
||||
}
|
||||
if hasAlpha {
|
||||
for f in a_ac {
|
||||
let f15 = round(15.0 * f)
|
||||
let i15 = UInt8(f15)
|
||||
if isOdd {
|
||||
hash[hash.count - 1] |= i15 << 4
|
||||
} else {
|
||||
hash.append(i15)
|
||||
}
|
||||
isOdd = !isOdd
|
||||
}
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func thumbHashToRGBA(hash: Data) -> (Int, Int, Data) {
|
||||
// Read the constants
|
||||
let h0 = UInt32(hash[0])
|
||||
let h1 = UInt32(hash[1])
|
||||
let h2 = UInt32(hash[2])
|
||||
let h3 = UInt16(hash[3])
|
||||
let h4 = UInt16(hash[4])
|
||||
let header24 = h0 | (h1 << 8) | (h2 << 16)
|
||||
let header16 = h3 | (h4 << 8)
|
||||
let il_dc = header24 & 63
|
||||
let ip_dc = (header24 >> 6) & 63
|
||||
let iq_dc = (header24 >> 12) & 63
|
||||
var l_dc = Float32(il_dc)
|
||||
var p_dc = Float32(ip_dc)
|
||||
var q_dc = Float32(iq_dc)
|
||||
l_dc = l_dc / 63
|
||||
p_dc = p_dc / 31.5 - 1
|
||||
q_dc = q_dc / 31.5 - 1
|
||||
let il_scale = (header24 >> 18) & 31
|
||||
var l_scale = Float32(il_scale)
|
||||
l_scale = l_scale / 31
|
||||
let hasAlpha = (header24 >> 23) != 0
|
||||
let ip_scale = (header16 >> 3) & 63
|
||||
let iq_scale = (header16 >> 9) & 63
|
||||
var p_scale = Float32(ip_scale)
|
||||
var q_scale = Float32(iq_scale)
|
||||
p_scale = p_scale / 63
|
||||
q_scale = q_scale / 63
|
||||
let isLandscape = (header16 >> 15) != 0
|
||||
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
|
||||
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
|
||||
let lx = Int(lx16)
|
||||
let ly = Int(ly16)
|
||||
var a_dc = Float32(1)
|
||||
var a_scale = Float32(1)
|
||||
if hasAlpha {
|
||||
let ia_dc = hash[5] & 15
|
||||
let ia_scale = hash[5] >> 4
|
||||
a_dc = Float32(ia_dc)
|
||||
a_scale = Float32(ia_scale)
|
||||
a_dc /= 15
|
||||
a_scale /= 15
|
||||
}
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
let ac_start = hasAlpha ? 6 : 5
|
||||
var ac_index = 0
|
||||
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
|
||||
var ac: [Float32] = []
|
||||
for cy in 0 ..< ny {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
while cx * ny < nx * (ny - cy) {
|
||||
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15
|
||||
var fac = Float32(iac)
|
||||
fac = (fac / 7.5 - 1) * scale
|
||||
ac.append(fac)
|
||||
ac_index += 1
|
||||
cx += 1
|
||||
}
|
||||
}
|
||||
return ac
|
||||
}
|
||||
let l_ac = decodeChannel(lx, ly, l_scale)
|
||||
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
|
||||
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
|
||||
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
|
||||
let fw = round(ratio > 1 ? 32 : 32 * ratio)
|
||||
let fh = round(ratio > 1 ? 32 / ratio : 32)
|
||||
let w = Int(fw)
|
||||
let h = Int(fh)
|
||||
var rgba = Data(count: w * h * 4)
|
||||
let cx_stop = max(lx, hasAlpha ? 5 : 3)
|
||||
let cy_stop = max(ly, hasAlpha ? 5 : 3)
|
||||
var fx = [Float32](repeating: 0, count: cx_stop)
|
||||
var fy = [Float32](repeating: 0, count: cy_stop)
|
||||
fx.withUnsafeMutableBytes { fx in
|
||||
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
|
||||
fy.withUnsafeMutableBytes { fy in
|
||||
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
|
||||
rgba.withUnsafeMutableBytes { rgba in
|
||||
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
|
||||
var y = 0
|
||||
while y < h {
|
||||
var x = 0
|
||||
while x < w {
|
||||
var l = l_dc
|
||||
var p = p_dc
|
||||
var q = q_dc
|
||||
var a = a_dc
|
||||
|
||||
// Precompute the coefficients
|
||||
var cx = 0
|
||||
while cx < cx_stop {
|
||||
let fw = Float32(w)
|
||||
let fxx = Float32(x)
|
||||
let fcx = Float32(cx)
|
||||
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
|
||||
cx += 1
|
||||
}
|
||||
var cy = 0
|
||||
while cy < cy_stop {
|
||||
let fh = Float32(h)
|
||||
let fyy = Float32(y)
|
||||
let fcy = Float32(cy)
|
||||
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
|
||||
cy += 1
|
||||
}
|
||||
|
||||
// Decode L
|
||||
var j = 0
|
||||
cy = 0
|
||||
while cy < ly {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
let fy2 = fy[cy] * 2
|
||||
while cx * ly < lx * (ly - cy) {
|
||||
l += l_ac[j] * fx[cx] * fy2
|
||||
j += 1
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
|
||||
// Decode P and Q
|
||||
j = 0
|
||||
cy = 0
|
||||
while cy < 3 {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
let fy2 = fy[cy] * 2
|
||||
while cx < 3 - cy {
|
||||
let f = fx[cx] * fy2
|
||||
p += p_ac[j] * f
|
||||
q += q_ac[j] * f
|
||||
j += 1
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if hasAlpha {
|
||||
j = 0
|
||||
cy = 0
|
||||
while cy < 5 {
|
||||
var cx = cy > 0 ? 0 : 1
|
||||
let fy2 = fy[cy] * 2
|
||||
while cx < 5 - cy {
|
||||
a += a_ac[j] * fx[cx] * fy2
|
||||
j += 1
|
||||
cx += 1
|
||||
}
|
||||
cy += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to RGB
|
||||
var b = l - 2 / 3 * p
|
||||
var r = (3 * l - b + q) / 2
|
||||
var g = r - q
|
||||
r = max(0, 255 * min(1, r))
|
||||
g = max(0, 255 * min(1, g))
|
||||
b = max(0, 255 * min(1, b))
|
||||
a = max(0, 255 * min(1, a))
|
||||
rgba[0] = UInt8(r)
|
||||
rgba[1] = UInt8(g)
|
||||
rgba[2] = UInt8(b)
|
||||
rgba[3] = UInt8(a)
|
||||
rgba = rgba.advanced(by: 4)
|
||||
x += 1
|
||||
}
|
||||
y += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (w, h, rgba)
|
||||
}
|
||||
|
||||
func thumbHashToAverageRGBA(hash: Data) -> (Float32, Float32, Float32, Float32) {
|
||||
let h0 = UInt32(hash[0])
|
||||
let h1 = UInt32(hash[1])
|
||||
let h2 = UInt32(hash[2])
|
||||
let header = h0 | (h1 << 8) | (h2 << 16)
|
||||
let il = header & 63
|
||||
let ip = (header >> 6) & 63
|
||||
let iq = (header >> 12) & 63
|
||||
var l = Float32(il)
|
||||
var p = Float32(ip)
|
||||
var q = Float32(iq)
|
||||
l = l / 63
|
||||
p = p / 31.5 - 1
|
||||
q = q / 31.5 - 1
|
||||
let hasAlpha = (header >> 23) != 0
|
||||
var a = Float32(1)
|
||||
if hasAlpha {
|
||||
let ia = hash[5] & 15
|
||||
a = Float32(ia)
|
||||
a = a / 15
|
||||
}
|
||||
let b = l - 2 / 3 * p
|
||||
let r = (3 * l - b + q) / 2
|
||||
let g = r - q
|
||||
return (
|
||||
max(0, min(1, r)),
|
||||
max(0, min(1, g)),
|
||||
max(0, min(1, b)),
|
||||
a
|
||||
)
|
||||
}
|
||||
|
||||
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
|
||||
let header = hash[3]
|
||||
let hasAlpha = (hash[2] & 0x80) != 0
|
||||
let isLandscape = (hash[4] & 0x80) != 0
|
||||
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
|
||||
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
|
||||
return Float32(lx) / Float32(ly)
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
import UIKit
|
||||
|
||||
func thumbHash(fromImage: UIImage) -> Data {
|
||||
let size = fromImage.size
|
||||
let w = Int(round(100 * size.width / max(size.width, size.height)))
|
||||
let h = Int(round(100 * size.height / max(size.width, size.height)))
|
||||
var rgba = Data(count: w * h * 4)
|
||||
rgba.withUnsafeMutableBytes { rgba in
|
||||
if
|
||||
let space = fromImage.cgImage?.colorSpace,
|
||||
let context = CGContext(
|
||||
data: rgba.baseAddress,
|
||||
width: w,
|
||||
height: h,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: w * 4,
|
||||
space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)
|
||||
{
|
||||
// EXIF orientation only works if you draw the UIImage, not the CGImage
|
||||
context.concatenate(CGAffineTransform(1, 0, 0, -1, 0, CGFloat(h)))
|
||||
UIGraphicsPushContext(context)
|
||||
fromImage.draw(in: CGRect(x: 0, y: 0, width: w, height: h))
|
||||
UIGraphicsPopContext()
|
||||
|
||||
// Convert from premultiplied alpha to unpremultiplied alpha
|
||||
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
|
||||
let n = w * h
|
||||
var i = 0
|
||||
while i < n {
|
||||
let a = UInt16(rgba[3])
|
||||
if a > 0 && a < 255 {
|
||||
var r = UInt16(rgba[0])
|
||||
var g = UInt16(rgba[1])
|
||||
var b = UInt16(rgba[2])
|
||||
r = min(255, r * 255 / a)
|
||||
g = min(255, g * 255 / a)
|
||||
b = min(255, b * 255 / a)
|
||||
rgba[0] = UInt8(r)
|
||||
rgba[1] = UInt8(g)
|
||||
rgba[2] = UInt8(b)
|
||||
}
|
||||
rgba = rgba.advanced(by: 4)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return rgbaToThumbHash(w: w, h: h, rgba: rgba)
|
||||
}
|
||||
|
||||
func image(fromThumbhash: Data) -> UIImage {
|
||||
var (w, h, rgba) = thumbHashToRGBA(hash: fromThumbhash)
|
||||
rgba.withUnsafeMutableBytes { rgba in
|
||||
// Convert from unpremultiplied alpha to premultiplied alpha
|
||||
var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count)
|
||||
let n = w * h
|
||||
var i = 0
|
||||
while i < n {
|
||||
let a = UInt16(rgba[3])
|
||||
if a < 255 {
|
||||
var r = UInt16(rgba[0])
|
||||
var g = UInt16(rgba[1])
|
||||
var b = UInt16(rgba[2])
|
||||
r = min(255, r * a / 255)
|
||||
g = min(255, g * a / 255)
|
||||
b = min(255, b * a / 255)
|
||||
rgba[0] = UInt8(r)
|
||||
rgba[1] = UInt8(g)
|
||||
rgba[2] = UInt8(b)
|
||||
}
|
||||
rgba = rgba.advanced(by: 4)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
let image = CGImage(
|
||||
width: w,
|
||||
height: h,
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
bytesPerRow: w * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||
provider: CGDataProvider(data: rgba as CFData)!,
|
||||
decode: nil,
|
||||
shouldInterpolate: true,
|
||||
intent: .perceptual
|
||||
)
|
||||
return UIImage(cgImage: image!)
|
||||
}
|
||||
#endif
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import SDWebImage
|
||||
import ExpoModulesCore
|
||||
|
||||
class ThumbhashLoader: NSObject, SDImageLoader {
|
||||
// MARK: - SDImageLoader
|
||||
|
||||
func canRequestImage(for url: URL?) -> Bool {
|
||||
return url?.scheme == "thumbhash"
|
||||
}
|
||||
|
||||
func requestImage(
|
||||
with url: URL?,
|
||||
options: SDWebImageOptions = [],
|
||||
context: [SDWebImageContextOption: Any]?,
|
||||
progress progressBlock: SDImageLoaderProgressBlock?,
|
||||
completed completedBlock: SDImageLoaderCompletedBlock? = nil
|
||||
) -> SDWebImageOperation? {
|
||||
guard let url = url else {
|
||||
let error = makeNSError(description: "URL provided to ThumbhashLoader is missing")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return nil
|
||||
}
|
||||
// The URI looks like this: thumbhash:/3OcRJYB4d3h\iIeHeEh3eIhw+j2w
|
||||
// ThumbHash may include slashes which could break the structure of the URL, so we replace them
|
||||
// with backslashes on the JS side and revert them back to slashes here, before generating the image.
|
||||
var thumbhash = url.pathComponents[1].replacingOccurrences(of: "\\", with: "/")
|
||||
|
||||
// Thumbhashes with transparency cause the conversion to data to fail, padding the thumbhash string to correct length fixes that
|
||||
let remainder = thumbhash.count % 4
|
||||
if remainder > 0 {
|
||||
thumbhash = thumbhash.padding(toLength: thumbhash.count + 4 - remainder, withPad: "=", startingAt: 0)
|
||||
}
|
||||
|
||||
guard !thumbhash.isEmpty, let thumbhashData = Data(base64Encoded: thumbhash, options: .ignoreUnknownCharacters) else {
|
||||
let error = makeNSError(description: "URL provided to ThumbhashLoader is invalid")
|
||||
completedBlock?(nil, nil, error, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let image = image(fromThumbhash: thumbhashData)
|
||||
DispatchQueue.main.async {
|
||||
completedBlock?(image, nil, nil, true)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldBlockFailedURL(with url: URL, error: Error) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
// Copyright 2015-present 650 Industries. All rights reserved.
|
||||
|
||||
import SDWebImage
|
||||
import SDWebImageWebPCoder
|
||||
|
||||
internal let imageCoderOptionUseAppleWebpCodec = SDImageCoderOption(rawValue: "useAppleWebpCodec")
|
||||
|
||||
/**
|
||||
A composite WebP coder that uses either `SDImageAWebPCoder` or `SDImageWebPCoder`
|
||||
based on the passed `imageCoderOptionUseAppleWebpCodec` option.
|
||||
*/
|
||||
internal final class WebPCoder: NSObject, SDAnimatedImageCoder {
|
||||
static let shared = WebPCoder()
|
||||
|
||||
private var useAppleWebpCodec: Bool = true
|
||||
private var instantiatedCoder: SDAnimatedImageCoder?
|
||||
private var coder: SDAnimatedImageCoder {
|
||||
if let instantiatedCoder {
|
||||
return instantiatedCoder
|
||||
}
|
||||
return self.useAppleWebpCodec ? SDImageAWebPCoder.shared : SDImageWebPCoder.shared
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - SDAnimatedImageCoder implementations
|
||||
|
||||
convenience init(animatedImageData data: Data?, options: [SDImageCoderOption: Any]? = nil) {
|
||||
self.init()
|
||||
self.useAppleWebpCodec = options?[imageCoderOptionUseAppleWebpCodec] as? Bool ?? true
|
||||
self.instantiatedCoder = self.useAppleWebpCodec
|
||||
? SDImageAWebPCoder.init(animatedImageData: data, options: options)
|
||||
: SDImageWebPCoder.init(animatedImageData: data, options: options)
|
||||
}
|
||||
|
||||
func canDecode(from data: Data?) -> Bool {
|
||||
return self.coder.canDecode(from: data)
|
||||
}
|
||||
|
||||
func decodedImage(with data: Data?, options: [SDImageCoderOption: Any]? = nil) -> UIImage? {
|
||||
return self.coder.decodedImage(with: data, options: options)
|
||||
}
|
||||
|
||||
func canEncode(to format: SDImageFormat) -> Bool {
|
||||
return self.coder.canEncode(to: format)
|
||||
}
|
||||
|
||||
func encodedData(with image: UIImage?, format: SDImageFormat, options: [SDImageCoderOption: Any]? = nil) -> Data? {
|
||||
return self.coder.encodedData(with: image, format: format, options: options)
|
||||
}
|
||||
|
||||
var animatedImageData: Data? {
|
||||
return self.coder.animatedImageData
|
||||
}
|
||||
|
||||
var animatedImageFrameCount: UInt {
|
||||
return self.coder.animatedImageFrameCount
|
||||
}
|
||||
|
||||
var animatedImageLoopCount: UInt {
|
||||
return self.coder.animatedImageLoopCount
|
||||
}
|
||||
|
||||
func animatedImageFrame(at index: UInt) -> UIImage? {
|
||||
return self.coder.animatedImageFrame(at: index)
|
||||
}
|
||||
|
||||
func animatedImageDuration(at index: UInt) -> TimeInterval {
|
||||
return self.coder.animatedImageDuration(at: index)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user