// 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>?) in if let imageRef: SharedRef = 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("sfEffect") { (view, sfEffect: [SFSymbolEffect]?) in view.sfEffect = sfEffect } Prop("symbolWeight") { (view, symbolWeight: String?) in view.symbolWeight = symbolWeight } Prop("symbolSize") { (view, symbolSize: Double?) in view.symbolSize = symbolSize } Prop("useAppleWebpCodec", true) { (view, useAppleWebpCodec: Bool) in view.useAppleWebpCodec = useAppleWebpCodec } Prop("enforceEarlyResizing", false) { (view, enforceEarlyResizing: Bool) in view.enforceEarlyResizing = enforceEarlyResizing } Prop("preferHighDynamicRange", false) { (view, preferHighDynamicRange: Bool) in if #available(iOS 17.0, macCatalyst 17.0, tvOS 17.0, *) { view.sdImageView.preferredImageDynamicRange = preferHighDynamicRange ? .constrainedHigh : .unspecified } } AsyncFunction("startAnimating") { (view: ImageView) in if view.isSFSymbolSource { view.startSymbolAnimation() } else { view.sdImageView.startAnimating() } } AsyncFunction("stopAnimating") { (view: ImageView) in if view.isSFSymbolSource { view.stopSymbolAnimation() } else { 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() } } Function("configureCache") { (config: ImageCacheConfig) in ImageModule.configureCache(config: config) } 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, 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, 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, options: options ?? ImageLoadOptions()).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, 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(PSDCoder.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()) SDImageLoadersManager.shared.addLoader(SFSymbolLoader()) } static func configureCache(config: ImageCacheConfig) { if let maxMemoryCount = config.maxMemoryCount { SDImageCache.shared.config.maxMemoryCount = maxMemoryCount } if let maxDiskSize = config.maxDiskSize { SDImageCache.shared.config.maxDiskSize = maxDiskSize } if let maxMemoryCost = config.maxMemoryCost { SDImageCache.shared.config.maxMemoryCost = maxMemoryCost } } }