├── .gitignore ├── LICENSE.txt ├── Readme.md ├── SampleProject ├── SampleProject.xcodeproj │ └── project.pbxproj └── SampleProject │ ├── AppDelegate.h │ ├── AppDelegate.m │ ├── Assets.xcassets │ └── AppIcon.appiconset │ │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── ViewController.h │ ├── ViewController.m │ └── main.m ├── Social Preview Diagram.diagrams └── TJImageCache ├── Deprecated ├── TJFastImage.h ├── TJFastImage.m ├── TJFastImageView.h └── TJFastImageView.m ├── NSItemProvider+TJImageCache.h ├── NSItemProvider+TJImageCache.m ├── TJImageCache.h ├── TJImageCache.m ├── TJImageView.h ├── TJImageView.m ├── TJProgressiveImageView.h ├── TJProgressiveImageView.m ├── UIImageView+TJImageCache.h └── UIImageView+TJImageCache.m /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *xcuserdata* 3 | *xcworkspace* -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2016, Tim Johnsen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | * Neither the name of Tim Johnsen nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # TJImageCache 2 | 3 | ## Configuring 4 | 5 | You must configure the cache using either `+configureWithDefaultRootPath` or `+configureWithRootPath:` before attempting to load any images, I recommend doing so in `-application:didFinishLaunchingWithOptions:`. `+configureWithDefaultRootPath` is best if you have a standalone app, but `+configureWithRootPath:` is useful when building extensions. 6 | 7 | ## Fetching an Image 8 | 9 | To fetch an image, use one of the following methods. 10 | 11 | 1. `+imageAtURL:depth:delegate:backgroundDecode:` 12 | 2. `+imageAtURL:depth:delegate:` 13 | 3. `+imageAtURL:delegate:` 14 | 4. `+imageAtURL:depth:` 15 | 5. `+imageAtURL:` 16 | 17 | In the event that the image is already in memory, each of these methods returns an image. If not, the `TJImageCacheDelegate` methods will be called back on the delegate you provide. 18 | 19 | You can cancel an in-progress image load using `+cancelImageLoadForURL:delegate:`. 20 | 21 | ## Image Views 22 | 23 | TJImageCache comes with some convenience views / categories for working directly with views. There's a few that I've built for different purposes over time. 24 | 25 | - `UIImageView+TJImageCache` is a category that adds remote image loading methods to `UIImageView`. It's a simple drop-in solution. 26 | - `TJProgressiveImageView` allows you to specify more than one image to load progressively. The image at index 0 is always loaded with max depth = network, and secondary images are loaded opportunistically with a depth you provide ("disk" depth recommended). 27 | - `TJFastImageView` (Deprecated) is a performance-tuned image view subclass that rounds its contents and adds a stroke around their border off the main thread. This was originally written to make [Opener](http://www.opener.link)'s app icon rendering buttery smooth. Might be a little heavy handed for everyday use. (There's also a `TJFastImageButton` class that has similar innards but for a `UIButton` that I was building for another app, but haven't touched in a long time. Your mileage may vary with that.) 28 | - `TJImageView` is the oldest convenience class this library provides. It may not be super performant, but is also good for general use. It has some niceties like a background color while the image is loading and a fade in animation once it loads. 29 | 30 | ## Auditing 31 | 32 | To implement your own cache auditing policy, you can use `+auditCacheWithBlock:completionBlock:`. `block` is invoked for every image the cache knows of on low priority a background thread, returning `NO` from the block means the image will be deleted, returning `YES` means it will be preserved. The completion block is invoked when cache auditing is finished. 33 | 34 | There are two convenience methods you can use to remove images based off of age, `+auditCacheRemovingFilesOlderThanDate:` and `+auditCacheRemovingFilesLastAccessedBeforeDate:`. Using these will remove images older than a certain date or images that were last accessed before a certain date respectively. 35 | 36 | ## Sizing 37 | 38 | `TJImageCache` has a handy feature that automatically tracks changes in its disk cache size. You can observe this using KVO on the `approximateDiskCacheSize` property. This property will be `nil` initially, but it is populated as a result of any of the three following method calls and updated from then on. 39 | 40 | - `+auditCache...` 41 | - `+computeDiskCacheSizeIfNeeded` 42 | - `+getDiskCacheSize:` 43 | 44 | Most apps will call one of the auditing methods to clean up their cache, which means automatic size tracking will usually happen for free with no additional method calls. If you need a simple, transactional way of getting the size of the cache you can use `+getDiskCacheSize:`. 45 | 46 | # Other Notes 47 | 48 | - `TJImageCache` plays quite nicely with [OLImageView](https://github.com/ondalabs/OLImageView) if you replace `IMAGE_CLASS` with `OLImage` in [TJImageCache.h](https://github.com/tijoinc/TJImageCache/blob/master/TJImageCache.h#L4). This allows you to load and play animated GIFs. 49 | - You can use `TJImageCache` in macOS apps by replacing `IMAGE_CLASS` with `NSImage`. 50 | -------------------------------------------------------------------------------- /SampleProject/SampleProject.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 229CEC831BADF75900BE44A1 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 229CEC821BADF75900BE44A1 /* main.m */; }; 11 | 229CEC861BADF75900BE44A1 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 229CEC851BADF75900BE44A1 /* AppDelegate.m */; }; 12 | 229CEC891BADF75900BE44A1 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 229CEC881BADF75900BE44A1 /* ViewController.m */; }; 13 | 229CEC8E1BADF75900BE44A1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 229CEC8D1BADF75900BE44A1 /* Assets.xcassets */; }; 14 | 229CEC911BADF75900BE44A1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 229CEC8F1BADF75900BE44A1 /* LaunchScreen.storyboard */; }; 15 | 22A3A1931BADF8BD00442CF2 /* TJImageCache.m in Sources */ = {isa = PBXBuildFile; fileRef = 22A3A1901BADF8BD00442CF2 /* TJImageCache.m */; settings = {ASSET_TAGS = (); }; }; 16 | 22A3A1941BADF8BD00442CF2 /* TJImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 22A3A1921BADF8BD00442CF2 /* TJImageView.m */; settings = {ASSET_TAGS = (); }; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | 229CEC7E1BADF75900BE44A1 /* SampleProject.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SampleProject.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | 229CEC821BADF75900BE44A1 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 22 | 229CEC841BADF75900BE44A1 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 23 | 229CEC851BADF75900BE44A1 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 24 | 229CEC871BADF75900BE44A1 /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; 25 | 229CEC881BADF75900BE44A1 /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; 26 | 229CEC8D1BADF75900BE44A1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 27 | 229CEC901BADF75900BE44A1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 28 | 229CEC921BADF75900BE44A1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 29 | 22A3A18F1BADF8BD00442CF2 /* TJImageCache.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TJImageCache.h; sourceTree = ""; }; 30 | 22A3A1901BADF8BD00442CF2 /* TJImageCache.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TJImageCache.m; sourceTree = ""; }; 31 | 22A3A1911BADF8BD00442CF2 /* TJImageView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TJImageView.h; sourceTree = ""; }; 32 | 22A3A1921BADF8BD00442CF2 /* TJImageView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TJImageView.m; sourceTree = ""; }; 33 | /* End PBXFileReference section */ 34 | 35 | /* Begin PBXFrameworksBuildPhase section */ 36 | 229CEC7B1BADF75900BE44A1 /* Frameworks */ = { 37 | isa = PBXFrameworksBuildPhase; 38 | buildActionMask = 2147483647; 39 | files = ( 40 | ); 41 | runOnlyForDeploymentPostprocessing = 0; 42 | }; 43 | /* End PBXFrameworksBuildPhase section */ 44 | 45 | /* Begin PBXGroup section */ 46 | 229CEC751BADF75900BE44A1 = { 47 | isa = PBXGroup; 48 | children = ( 49 | 229CEC801BADF75900BE44A1 /* SampleProject */, 50 | 229CEC7F1BADF75900BE44A1 /* Products */, 51 | ); 52 | sourceTree = ""; 53 | }; 54 | 229CEC7F1BADF75900BE44A1 /* Products */ = { 55 | isa = PBXGroup; 56 | children = ( 57 | 229CEC7E1BADF75900BE44A1 /* SampleProject.app */, 58 | ); 59 | name = Products; 60 | sourceTree = ""; 61 | }; 62 | 229CEC801BADF75900BE44A1 /* SampleProject */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | 229CEC841BADF75900BE44A1 /* AppDelegate.h */, 66 | 229CEC851BADF75900BE44A1 /* AppDelegate.m */, 67 | 229CEC871BADF75900BE44A1 /* ViewController.h */, 68 | 229CEC881BADF75900BE44A1 /* ViewController.m */, 69 | 22A3A18E1BADF8BD00442CF2 /* TJImageCache */, 70 | 229CEC8D1BADF75900BE44A1 /* Assets.xcassets */, 71 | 229CEC8F1BADF75900BE44A1 /* LaunchScreen.storyboard */, 72 | 229CEC921BADF75900BE44A1 /* Info.plist */, 73 | 229CEC811BADF75900BE44A1 /* Supporting Files */, 74 | ); 75 | path = SampleProject; 76 | sourceTree = ""; 77 | }; 78 | 229CEC811BADF75900BE44A1 /* Supporting Files */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | 229CEC821BADF75900BE44A1 /* main.m */, 82 | ); 83 | name = "Supporting Files"; 84 | sourceTree = ""; 85 | }; 86 | 22A3A18E1BADF8BD00442CF2 /* TJImageCache */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | 22A3A18F1BADF8BD00442CF2 /* TJImageCache.h */, 90 | 22A3A1901BADF8BD00442CF2 /* TJImageCache.m */, 91 | 22A3A1911BADF8BD00442CF2 /* TJImageView.h */, 92 | 22A3A1921BADF8BD00442CF2 /* TJImageView.m */, 93 | ); 94 | name = TJImageCache; 95 | path = ../../TJImageCache; 96 | sourceTree = ""; 97 | }; 98 | /* End PBXGroup section */ 99 | 100 | /* Begin PBXNativeTarget section */ 101 | 229CEC7D1BADF75900BE44A1 /* SampleProject */ = { 102 | isa = PBXNativeTarget; 103 | buildConfigurationList = 229CEC951BADF75900BE44A1 /* Build configuration list for PBXNativeTarget "SampleProject" */; 104 | buildPhases = ( 105 | 229CEC7A1BADF75900BE44A1 /* Sources */, 106 | 229CEC7B1BADF75900BE44A1 /* Frameworks */, 107 | 229CEC7C1BADF75900BE44A1 /* Resources */, 108 | ); 109 | buildRules = ( 110 | ); 111 | dependencies = ( 112 | ); 113 | name = SampleProject; 114 | productName = SampleProject; 115 | productReference = 229CEC7E1BADF75900BE44A1 /* SampleProject.app */; 116 | productType = "com.apple.product-type.application"; 117 | }; 118 | /* End PBXNativeTarget section */ 119 | 120 | /* Begin PBXProject section */ 121 | 229CEC761BADF75900BE44A1 /* Project object */ = { 122 | isa = PBXProject; 123 | attributes = { 124 | LastUpgradeCheck = 0700; 125 | ORGANIZATIONNAME = tijo; 126 | TargetAttributes = { 127 | 229CEC7D1BADF75900BE44A1 = { 128 | CreatedOnToolsVersion = 7.0; 129 | }; 130 | }; 131 | }; 132 | buildConfigurationList = 229CEC791BADF75900BE44A1 /* Build configuration list for PBXProject "SampleProject" */; 133 | compatibilityVersion = "Xcode 3.2"; 134 | developmentRegion = English; 135 | hasScannedForEncodings = 0; 136 | knownRegions = ( 137 | en, 138 | Base, 139 | ); 140 | mainGroup = 229CEC751BADF75900BE44A1; 141 | productRefGroup = 229CEC7F1BADF75900BE44A1 /* Products */; 142 | projectDirPath = ""; 143 | projectRoot = ""; 144 | targets = ( 145 | 229CEC7D1BADF75900BE44A1 /* SampleProject */, 146 | ); 147 | }; 148 | /* End PBXProject section */ 149 | 150 | /* Begin PBXResourcesBuildPhase section */ 151 | 229CEC7C1BADF75900BE44A1 /* Resources */ = { 152 | isa = PBXResourcesBuildPhase; 153 | buildActionMask = 2147483647; 154 | files = ( 155 | 229CEC911BADF75900BE44A1 /* LaunchScreen.storyboard in Resources */, 156 | 229CEC8E1BADF75900BE44A1 /* Assets.xcassets in Resources */, 157 | ); 158 | runOnlyForDeploymentPostprocessing = 0; 159 | }; 160 | /* End PBXResourcesBuildPhase section */ 161 | 162 | /* Begin PBXSourcesBuildPhase section */ 163 | 229CEC7A1BADF75900BE44A1 /* Sources */ = { 164 | isa = PBXSourcesBuildPhase; 165 | buildActionMask = 2147483647; 166 | files = ( 167 | 229CEC891BADF75900BE44A1 /* ViewController.m in Sources */, 168 | 229CEC861BADF75900BE44A1 /* AppDelegate.m in Sources */, 169 | 22A3A1931BADF8BD00442CF2 /* TJImageCache.m in Sources */, 170 | 22A3A1941BADF8BD00442CF2 /* TJImageView.m in Sources */, 171 | 229CEC831BADF75900BE44A1 /* main.m in Sources */, 172 | ); 173 | runOnlyForDeploymentPostprocessing = 0; 174 | }; 175 | /* End PBXSourcesBuildPhase section */ 176 | 177 | /* Begin PBXVariantGroup section */ 178 | 229CEC8F1BADF75900BE44A1 /* LaunchScreen.storyboard */ = { 179 | isa = PBXVariantGroup; 180 | children = ( 181 | 229CEC901BADF75900BE44A1 /* Base */, 182 | ); 183 | name = LaunchScreen.storyboard; 184 | sourceTree = ""; 185 | }; 186 | /* End PBXVariantGroup section */ 187 | 188 | /* Begin XCBuildConfiguration section */ 189 | 229CEC931BADF75900BE44A1 /* Debug */ = { 190 | isa = XCBuildConfiguration; 191 | buildSettings = { 192 | ALWAYS_SEARCH_USER_PATHS = NO; 193 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 194 | CLANG_CXX_LIBRARY = "libc++"; 195 | CLANG_ENABLE_MODULES = YES; 196 | CLANG_ENABLE_OBJC_ARC = YES; 197 | CLANG_WARN_BOOL_CONVERSION = YES; 198 | CLANG_WARN_CONSTANT_CONVERSION = YES; 199 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 200 | CLANG_WARN_EMPTY_BODY = YES; 201 | CLANG_WARN_ENUM_CONVERSION = YES; 202 | CLANG_WARN_INT_CONVERSION = YES; 203 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 204 | CLANG_WARN_UNREACHABLE_CODE = YES; 205 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 206 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 207 | COPY_PHASE_STRIP = NO; 208 | DEBUG_INFORMATION_FORMAT = dwarf; 209 | ENABLE_STRICT_OBJC_MSGSEND = YES; 210 | ENABLE_TESTABILITY = YES; 211 | GCC_C_LANGUAGE_STANDARD = gnu99; 212 | GCC_DYNAMIC_NO_PIC = NO; 213 | GCC_NO_COMMON_BLOCKS = YES; 214 | GCC_OPTIMIZATION_LEVEL = 0; 215 | GCC_PREPROCESSOR_DEFINITIONS = ( 216 | "DEBUG=1", 217 | "$(inherited)", 218 | ); 219 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 220 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 221 | GCC_WARN_UNDECLARED_SELECTOR = YES; 222 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 223 | GCC_WARN_UNUSED_FUNCTION = YES; 224 | GCC_WARN_UNUSED_VARIABLE = YES; 225 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 226 | MTL_ENABLE_DEBUG_INFO = YES; 227 | ONLY_ACTIVE_ARCH = YES; 228 | SDKROOT = iphoneos; 229 | }; 230 | name = Debug; 231 | }; 232 | 229CEC941BADF75900BE44A1 /* Release */ = { 233 | isa = XCBuildConfiguration; 234 | buildSettings = { 235 | ALWAYS_SEARCH_USER_PATHS = NO; 236 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 237 | CLANG_CXX_LIBRARY = "libc++"; 238 | CLANG_ENABLE_MODULES = YES; 239 | CLANG_ENABLE_OBJC_ARC = YES; 240 | CLANG_WARN_BOOL_CONVERSION = YES; 241 | CLANG_WARN_CONSTANT_CONVERSION = YES; 242 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 243 | CLANG_WARN_EMPTY_BODY = YES; 244 | CLANG_WARN_ENUM_CONVERSION = YES; 245 | CLANG_WARN_INT_CONVERSION = YES; 246 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 247 | CLANG_WARN_UNREACHABLE_CODE = YES; 248 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 249 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 250 | COPY_PHASE_STRIP = NO; 251 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 252 | ENABLE_NS_ASSERTIONS = NO; 253 | ENABLE_STRICT_OBJC_MSGSEND = YES; 254 | GCC_C_LANGUAGE_STANDARD = gnu99; 255 | GCC_NO_COMMON_BLOCKS = YES; 256 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 257 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 258 | GCC_WARN_UNDECLARED_SELECTOR = YES; 259 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 260 | GCC_WARN_UNUSED_FUNCTION = YES; 261 | GCC_WARN_UNUSED_VARIABLE = YES; 262 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 263 | MTL_ENABLE_DEBUG_INFO = NO; 264 | SDKROOT = iphoneos; 265 | VALIDATE_PRODUCT = YES; 266 | }; 267 | name = Release; 268 | }; 269 | 229CEC961BADF75900BE44A1 /* Debug */ = { 270 | isa = XCBuildConfiguration; 271 | buildSettings = { 272 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 273 | INFOPLIST_FILE = SampleProject/Info.plist; 274 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 275 | PRODUCT_BUNDLE_IDENTIFIER = com.tijo.SampleProject; 276 | PRODUCT_NAME = "$(TARGET_NAME)"; 277 | }; 278 | name = Debug; 279 | }; 280 | 229CEC971BADF75900BE44A1 /* Release */ = { 281 | isa = XCBuildConfiguration; 282 | buildSettings = { 283 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 284 | INFOPLIST_FILE = SampleProject/Info.plist; 285 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 286 | PRODUCT_BUNDLE_IDENTIFIER = com.tijo.SampleProject; 287 | PRODUCT_NAME = "$(TARGET_NAME)"; 288 | }; 289 | name = Release; 290 | }; 291 | /* End XCBuildConfiguration section */ 292 | 293 | /* Begin XCConfigurationList section */ 294 | 229CEC791BADF75900BE44A1 /* Build configuration list for PBXProject "SampleProject" */ = { 295 | isa = XCConfigurationList; 296 | buildConfigurations = ( 297 | 229CEC931BADF75900BE44A1 /* Debug */, 298 | 229CEC941BADF75900BE44A1 /* Release */, 299 | ); 300 | defaultConfigurationIsVisible = 0; 301 | defaultConfigurationName = Release; 302 | }; 303 | 229CEC951BADF75900BE44A1 /* Build configuration list for PBXNativeTarget "SampleProject" */ = { 304 | isa = XCConfigurationList; 305 | buildConfigurations = ( 306 | 229CEC961BADF75900BE44A1 /* Debug */, 307 | 229CEC971BADF75900BE44A1 /* Release */, 308 | ); 309 | defaultConfigurationIsVisible = 0; 310 | defaultConfigurationName = Release; 311 | }; 312 | /* End XCConfigurationList section */ 313 | }; 314 | rootObject = 229CEC761BADF75900BE44A1 /* Project object */; 315 | } 316 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/AppDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.h 3 | // SampleProject 4 | // 5 | // Created by Tim Johnsen on 9/19/15. 6 | // Copyright © 2015 tijo. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface AppDelegate : UIResponder 12 | 13 | @property (strong, nonatomic) UIWindow *window; 14 | 15 | 16 | @end 17 | 18 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/AppDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.m 3 | // SampleProject 4 | // 5 | // Created by Tim Johnsen on 9/19/15. 6 | // Copyright © 2015 tijo. All rights reserved. 7 | // 8 | 9 | #import "AppDelegate.h" 10 | #import "TJImageCache.h" 11 | #import "ViewController.h" 12 | 13 | @interface AppDelegate () 14 | 15 | @end 16 | 17 | @implementation AppDelegate 18 | 19 | 20 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { 21 | [TJImageCache configureWithDefaultRootPath]; 22 | 23 | self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; 24 | self.window.backgroundColor = [UIColor whiteColor]; 25 | self.window.rootViewController = [[ViewController alloc] init]; 26 | [self.window makeKeyAndVisible]; 27 | return YES; 28 | } 29 | 30 | @end 31 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | } 33 | ], 34 | "info" : { 35 | "version" : 1, 36 | "author" : "xcode" 37 | } 38 | } -------------------------------------------------------------------------------- /SampleProject/SampleProject/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | UILaunchStoryboardName 31 | LaunchScreen 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/ViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.h 3 | // SampleProject 4 | // 5 | // Created by Tim Johnsen on 9/19/15. 6 | // Copyright © 2015 tijo. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface ViewController : UITableViewController 12 | 13 | 14 | @end 15 | 16 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/ViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.m 3 | // SampleProject 4 | // 5 | // Created by Tim Johnsen on 9/19/15. 6 | // Copyright © 2015 tijo. All rights reserved. 7 | // 8 | 9 | #import "ViewController.h" 10 | #import "TJImageView.h" 11 | 12 | @interface ViewController () 13 | 14 | @end 15 | 16 | @implementation ViewController 17 | 18 | - (void)viewDidLoad 19 | { 20 | [super viewDidLoad]; 21 | self.tableView.rowHeight = self.tableView.bounds.size.width; 22 | } 23 | 24 | - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView 25 | { 26 | return 1; 27 | } 28 | 29 | - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section 30 | { 31 | return 100; 32 | } 33 | 34 | - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath 35 | { 36 | static NSString *const kCellIdentifier = @"cell"; 37 | UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; 38 | static const NSInteger kCellImageViewTag = 101; 39 | TJImageView *imageView = (TJImageView *)[cell.contentView viewWithTag:kCellImageViewTag]; 40 | if (!cell) { 41 | cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kCellIdentifier]; 42 | imageView = [[TJImageView alloc] initWithFrame:cell.contentView.bounds]; 43 | imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 44 | imageView.tag = kCellImageViewTag; 45 | [cell.contentView addSubview:imageView]; 46 | } 47 | 48 | imageView.imageURLString = [NSString stringWithFormat:@"http://lorempixel.com/%zd/%zd", 200 + indexPath.row, 200 + indexPath.row / 5]; 49 | 50 | return cell; 51 | } 52 | 53 | @end 54 | -------------------------------------------------------------------------------- /SampleProject/SampleProject/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // SampleProject 4 | // 5 | // Created by Tim Johnsen on 9/19/15. 6 | // Copyright © 2015 tijo. All rights reserved. 7 | // 8 | 9 | #import 10 | #import "AppDelegate.h" 11 | 12 | int main(int argc, char * argv[]) { 13 | @autoreleasepool { 14 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Social Preview Diagram.diagrams: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timonus/TJImageCache/46fa1fcd44efc1ea2d6a2bb26c5d0a9369ee6cca/Social Preview Diagram.diagrams -------------------------------------------------------------------------------- /TJImageCache/Deprecated/TJFastImage.h: -------------------------------------------------------------------------------- 1 | // 2 | // TJFastImage.h 3 | // Mastodon 4 | // 5 | // Created by Tim Johnsen on 4/19/17. 6 | // Copyright © 2017 Tim Johnsen. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #ifndef TJFastImage_h 12 | #define TJFastImage_h 13 | 14 | // Must be thread safe 15 | UIImage *imageForImageSizeCornerRadius(UIImage *const image, const CGSize size, const CGFloat cornerRadius, UIColor *opaqueBackgroundColor); 16 | UIImage *placeholderImageWithCornerRadius(const CGFloat cornerRadius, UIColor *opaqueBackgroundColor); 17 | 18 | #endif /* TJFastImage_h */ 19 | -------------------------------------------------------------------------------- /TJImageCache/Deprecated/TJFastImage.m: -------------------------------------------------------------------------------- 1 | // 2 | // TJFastImage.m 3 | // Mastodon 4 | // 5 | // Created by Tim Johnsen on 4/19/17. 6 | // Copyright © 2017 Tim Johnsen. All rights reserved. 7 | // 8 | 9 | #import "TJFastImage.h" 10 | 11 | // Must be thread safe 12 | UIImage *drawImageWithBlockSizeOpaque(void (^drawBlock)(CGContextRef context), const CGSize size, UIColor *const opaqueBackgroundColor); 13 | UIImage *drawImageWithBlockSizeOpaque(void (^drawBlock)(CGContextRef context), const CGSize size, UIColor *const opaqueBackgroundColor) 14 | { 15 | UIImage *image = nil; 16 | const BOOL opaque = opaqueBackgroundColor != nil; 17 | if (opaque) { 18 | drawBlock = ^(CGContextRef context) { 19 | [opaqueBackgroundColor setFill]; 20 | CGContextFillRect(context, (CGRect){CGPointZero, size}); 21 | drawBlock(context); 22 | }; 23 | } 24 | #if defined(__IPHONE_10_0) && __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_10_0 25 | if (@available(iOS 10.0, *)) { 26 | #endif 27 | static UIGraphicsImageRendererFormat *transparentFormat = nil; 28 | static UIGraphicsImageRendererFormat *opaqueFormat = nil; 29 | static dispatch_once_t onceToken; 30 | dispatch_once(&onceToken, ^{ 31 | transparentFormat = [UIGraphicsImageRendererFormat new]; 32 | #if defined(__IPHONE_12_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_12_0 33 | // We assume the images we receive don't contain extended range colors. 34 | // Those colors are explicitly filtered out when drawing as an optimization. 35 | if (@available(iOS 12.0, *)) { 36 | transparentFormat.preferredRange = UIGraphicsImageRendererFormatRangeStandard; 37 | } else { 38 | #endif 39 | transparentFormat.prefersExtendedRange = NO; 40 | #if defined(__IPHONE_12_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_12_0 41 | } 42 | #endif 43 | }); 44 | UIGraphicsImageRendererFormat *format = nil; 45 | if (opaque) { 46 | static dispatch_once_t onceToken; 47 | dispatch_once(&onceToken, ^{ 48 | opaqueFormat = [transparentFormat copy]; 49 | opaqueFormat.opaque = YES; 50 | }); 51 | format = opaqueFormat; 52 | } else { 53 | format = transparentFormat; 54 | } 55 | UIGraphicsImageRenderer *const renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; 56 | image = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { 57 | drawBlock(rendererContext.CGContext); 58 | }]; 59 | #if defined(__IPHONE_10_0) && __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_10_0 60 | } else { 61 | UIGraphicsBeginImageContextWithOptions(size, opaque, [UIScreen mainScreen].scale); 62 | drawBlock(UIGraphicsGetCurrentContext()); 63 | image = UIGraphicsGetImageFromCurrentImageContext(); 64 | UIGraphicsEndImageContext(); 65 | } 66 | #endif 67 | return image; 68 | } 69 | 70 | 71 | UIImage *imageForImageSizeCornerRadius(UIImage *const image, const CGSize size, const CGFloat cornerRadius, UIColor *opaqueBackgroundColor) 72 | { 73 | UIImage *drawnImage = nil; 74 | if (size.width > 0.0 && size.height > 0.0) { 75 | const CGRect rect = (CGRect){CGPointZero, size}; 76 | const CGFloat scale = [UIScreen mainScreen].scale; 77 | drawnImage = drawImageWithBlockSizeOpaque(^(CGContextRef context) { 78 | UIBezierPath *const clippingPath = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:cornerRadius]; 79 | [clippingPath addClip]; // http://stackoverflow.com/a/13870097 80 | CGRect drawRect; 81 | if (rect.size.width / rect.size.height > image.size.width / image.size.height) { 82 | // Scale width 83 | CGFloat scaledHeight = floor(rect.size.width * (image.size.height / image.size.width)); 84 | drawRect = CGRectMake(0.0, floor((rect.size.height - scaledHeight) / 2.0), rect.size.width, scaledHeight); 85 | } else { 86 | // Scale height 87 | CGFloat scaledWidth = floor(rect.size.height * (image.size.width / image.size.height)); 88 | drawRect = CGRectMake(floor((rect.size.width - scaledWidth) / 2.0), 0.0, scaledWidth, rect.size.height); 89 | } 90 | [image drawInRect:drawRect]; 91 | [[UIColor lightGrayColor] setStroke]; 92 | CGContextSetLineWidth(context, MIN(1.0 / scale, 0.5)); 93 | [clippingPath stroke]; 94 | }, size, opaqueBackgroundColor); 95 | } 96 | return drawnImage; 97 | } 98 | 99 | UIImage *placeholderImageWithCornerRadius(const CGFloat cornerRadius, UIColor *opaqueBackgroundColor) 100 | { 101 | static NSCache *cachedImages = nil; 102 | static dispatch_once_t onceToken; 103 | dispatch_once(&onceToken, ^{ 104 | cachedImages = [NSCache new]; 105 | }); 106 | 107 | NSNumber *const key = @((NSUInteger)cornerRadius ^ [opaqueBackgroundColor hash]); 108 | UIImage *image = [cachedImages objectForKey:key]; 109 | if (!image) { 110 | const CGFloat sideLength = cornerRadius * 2.0 + 1.0; 111 | const CGSize size = (CGSize){sideLength, sideLength}; 112 | 113 | image = drawImageWithBlockSizeOpaque(^(CGContextRef context) { 114 | const CGRect rect = (CGRect){CGPointZero, size}; 115 | UIBezierPath *const clippingPath = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:cornerRadius]; 116 | [[UIColor lightGrayColor] setFill]; 117 | [clippingPath fill]; 118 | }, size, opaqueBackgroundColor); 119 | image = [image resizableImageWithCapInsets:(UIEdgeInsets){cornerRadius, cornerRadius, cornerRadius, cornerRadius}]; 120 | [cachedImages setObject:image forKey:key]; 121 | } 122 | 123 | return image; 124 | } 125 | -------------------------------------------------------------------------------- /TJImageCache/Deprecated/TJFastImageView.h: -------------------------------------------------------------------------------- 1 | // 2 | // TJFastImageView.h 3 | // Opener 4 | // 5 | // Created by Tim Johnsen on 4/13/17. 6 | // Copyright © 2017 tijo. All rights reserved. 7 | // 8 | 9 | #import 10 | #import "TJFastImage.h" 11 | 12 | @interface TJFastImageView : UIImageView 13 | 14 | @property (nonatomic, copy) NSString *imageURLString; 15 | @property (nonatomic, assign) CGFloat imageCornerRadius; 16 | @property (nonatomic, strong) UIColor *imageOpaqueBackgroundColor; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /TJImageCache/Deprecated/TJFastImageView.m: -------------------------------------------------------------------------------- 1 | // 2 | // TJFastImageView.m 3 | // Opener 4 | // 5 | // Created by Tim Johnsen on 4/13/17. 6 | // Copyright © 2017 tijo. All rights reserved. 7 | // 8 | 9 | #import "TJFastImageView.h" 10 | #import "TJImageCache.h" 11 | 12 | @interface TJFastImageView () 13 | 14 | @property (nonatomic, strong) UIImage *loadedImage; 15 | @property (nonatomic, assign) BOOL needsUpdateImage; 16 | 17 | @end 18 | 19 | @implementation TJFastImageView 20 | 21 | - (instancetype)initWithFrame:(CGRect)frame 22 | { 23 | if (self = [super initWithFrame:frame]) { 24 | [super setBackgroundColor:[UIColor clearColor]]; 25 | 26 | [[NSNotificationCenter defaultCenter] addObserver:self 27 | selector:@selector(invertColorsStatusDidChange:) 28 | name:UIAccessibilityInvertColorsStatusDidChangeNotification 29 | object:nil]; 30 | } 31 | return self; 32 | } 33 | 34 | - (void)invertColorsStatusDidChange:(NSNotification *)notification 35 | { 36 | [self setNeedsUpdateImage]; 37 | } 38 | 39 | - (void)setBackgroundColor:(UIColor *)backgroundColor 40 | { 41 | /* Intentionally left as a no-op so table view cells don't change our background color. */ 42 | } 43 | 44 | - (void)setImageURLString:(NSString *)imageURLString 45 | { 46 | if (imageURLString != _imageURLString && ![imageURLString isEqual:_imageURLString]) { 47 | _imageURLString = [imageURLString copy]; 48 | self.loadedImage = [TJImageCache imageAtURL:self.imageURLString delegate:self]; 49 | } 50 | } 51 | 52 | - (void)setImageCornerRadius:(CGFloat)imageCornerRadius 53 | { 54 | if (imageCornerRadius != _imageCornerRadius) { 55 | _imageCornerRadius = imageCornerRadius; 56 | [self setNeedsUpdateImage]; 57 | } 58 | } 59 | 60 | - (void)setImageOpaqueBackgroundColor:(UIColor *const)color 61 | { 62 | if (color != _imageOpaqueBackgroundColor && ![color isEqual:_imageOpaqueBackgroundColor]) { 63 | _imageOpaqueBackgroundColor = color; 64 | [self setNeedsUpdateImage]; 65 | } 66 | } 67 | 68 | - (void)setFrame:(CGRect)frame 69 | { 70 | const BOOL shouldUpdateImage = !CGSizeEqualToSize(frame.size, self.frame.size); 71 | [super setFrame:frame]; 72 | if (shouldUpdateImage) { 73 | [self setNeedsUpdateImage]; 74 | } 75 | } 76 | 77 | - (void)setLoadedImage:(UIImage *)loadedImage 78 | { 79 | if (loadedImage != _loadedImage && ![loadedImage isEqual:_loadedImage]) { 80 | _loadedImage = loadedImage; 81 | [self setNeedsUpdateImage]; 82 | } 83 | } 84 | 85 | - (void)didGetImage:(UIImage *)image atURL:(NSString *)url 86 | { 87 | if ([url isEqualToString:self.imageURLString] && !self.loadedImage) { 88 | self.loadedImage = image; 89 | } 90 | } 91 | 92 | - (void)setNeedsUpdateImage 93 | { 94 | self.needsUpdateImage = YES; 95 | [self setNeedsLayout]; 96 | } 97 | 98 | - (void)layoutSubviews 99 | { 100 | [super layoutSubviews]; 101 | if (self.needsUpdateImage) { 102 | [self updateImage]; 103 | } 104 | } 105 | 106 | /* Not to be called, similar to never calling -layoutSubviews. Call -setNeedsUpdateImage instead. */ 107 | - (void)updateImage 108 | { 109 | UIColor *opaqueBackgroundColor = nil; 110 | if (@available(iOS 11.0, *)) { 111 | opaqueBackgroundColor = self.accessibilityIgnoresInvertColors && UIAccessibilityIsInvertColorsEnabled() ? nil : self.imageOpaqueBackgroundColor; 112 | } else { 113 | opaqueBackgroundColor = self.imageOpaqueBackgroundColor; 114 | } 115 | self.image = placeholderImageWithCornerRadius(self.imageCornerRadius, opaqueBackgroundColor); 116 | 117 | UIImage *const image = self.loadedImage; 118 | if (image) { 119 | NSString *const imageURLString = self.imageURLString; 120 | const CGSize size = self.bounds.size; 121 | const CGFloat cornerRadius = self.imageCornerRadius; 122 | 123 | dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ 124 | UIImage *const drawnImage = imageForImageSizeCornerRadius(image, size, cornerRadius, opaqueBackgroundColor); 125 | dispatch_async(dispatch_get_main_queue(), ^{ 126 | /* These can mutate while scrolling quickly. We only want to accept the asynchronously drawn image if it matches our expectations. */ 127 | if ([imageURLString isEqualToString:self.imageURLString] && CGSizeEqualToSize(size, self.bounds.size) && cornerRadius == self.imageCornerRadius) { 128 | self.image = drawnImage; 129 | } 130 | }); 131 | }); 132 | } 133 | self.needsUpdateImage = NO; 134 | } 135 | 136 | 137 | @end 138 | -------------------------------------------------------------------------------- /TJImageCache/NSItemProvider+TJImageCache.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSItemProvider+TJImageCache.h 3 | // Wootie 4 | // 5 | // Created by Tim Johnsen on 5/13/20. 6 | // 7 | 8 | #import 9 | 10 | NS_ASSUME_NONNULL_BEGIN 11 | 12 | @interface NSItemProvider (TJImageCache) 13 | 14 | + (nullable instancetype)tj_itemProviderForImageURLString:(nullable NSString *const)imageURLString; 15 | 16 | @end 17 | 18 | NS_ASSUME_NONNULL_END 19 | -------------------------------------------------------------------------------- /TJImageCache/NSItemProvider+TJImageCache.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSItemProvider+TJImageCache.m 3 | // Wootie 4 | // 5 | // Created by Tim Johnsen on 5/13/20. 6 | // 7 | 8 | #import "NSItemProvider+TJImageCache.h" 9 | #import "TJImageCache.h" 10 | 11 | #import 12 | 13 | static char *const kTJImageCacheItemProviderLoadCompletionBlockKey = "kTJImageCacheItemProviderLoadCompletionBlockKey"; 14 | 15 | @interface NSItemProvider (TJImageCacheDelegate) 16 | 17 | @end 18 | 19 | #if defined(__has_attribute) && __has_attribute(objc_direct_members) 20 | __attribute__((objc_direct_members)) 21 | #endif 22 | @implementation NSItemProvider (TJImageCache) 23 | 24 | + (instancetype)tj_itemProviderForImageURLString:(NSString *const)imageURLString 25 | { 26 | NSItemProvider *itemProvider = nil; 27 | if (imageURLString) { 28 | itemProvider = [NSItemProvider new]; 29 | __weak NSItemProvider *const weakItemProvider = itemProvider; 30 | [itemProvider registerObjectOfClass:[UIImage class] 31 | visibility:NSItemProviderRepresentationVisibilityAll 32 | loadHandler:^NSProgress * _Nullable(void (^ _Nonnull completionHandler)(id _Nullable, NSError * _Nullable)) { 33 | UIImage *const image = [TJImageCache imageAtURL:imageURLString delegate:weakItemProvider]; 34 | if (image) { 35 | completionHandler(image, nil); 36 | } else { 37 | objc_setAssociatedObject(weakItemProvider, kTJImageCacheItemProviderLoadCompletionBlockKey, completionHandler, OBJC_ASSOCIATION_COPY); 38 | } 39 | return nil; 40 | }]; 41 | } 42 | return itemProvider; 43 | } 44 | 45 | @end 46 | 47 | #if defined(__has_attribute) && __has_attribute(objc_direct_members) 48 | __attribute__((objc_direct_members)) 49 | #endif 50 | @implementation NSItemProvider (TJImageCacheDelegate) 51 | 52 | static void _tryInvokeCallbackWithImage(NSItemProvider *const itemProvider, UIImage *const image) 53 | { 54 | void (^completionHandler)(id _Nullable object, NSError * _Nullable error) = objc_getAssociatedObject(itemProvider, kTJImageCacheItemProviderLoadCompletionBlockKey); 55 | if (completionHandler) { 56 | completionHandler(image, nil); 57 | } 58 | } 59 | 60 | - (void)didGetImage:(UIImage *)image atURL:(NSString *)url 61 | { 62 | _tryInvokeCallbackWithImage(self, image); 63 | } 64 | 65 | - (void)didFailToGetImageAtURL:(NSString *)url 66 | { 67 | _tryInvokeCallbackWithImage(self, nil); 68 | } 69 | 70 | @end 71 | -------------------------------------------------------------------------------- /TJImageCache/TJImageCache.h: -------------------------------------------------------------------------------- 1 | // TJImageCache 2 | // By Tim Johnsen 3 | 4 | // NOTE: To use in OS X, you should import AppKit and change IMAGE_CLASS to NSImage 5 | #import 6 | #define IMAGE_CLASS UIImage 7 | 8 | typedef NS_CLOSED_ENUM(NSUInteger, TJImageCacheDepth) { 9 | TJImageCacheDepthMemory, 10 | TJImageCacheDepthDisk, 11 | TJImageCacheDepthNetwork 12 | }; 13 | 14 | typedef NS_CLOSED_ENUM(NSUInteger, TJImageCacheCancellationPolicy) { 15 | TJImageCacheCancellationPolicyImageProcessing, // Only cancels image decompression, image is still downloaded 16 | TJImageCacheCancellationPolicyBeforeResponse, // Cancels request if a response hasn't yet been received 17 | TJImageCacheCancellationPolicyBeforeBody, // Cancels request if a body hasn't yet been received 18 | TJImageCacheCancellationPolicyUnconditional, // Cancels request unconditionally 19 | }; 20 | 21 | NS_ASSUME_NONNULL_BEGIN 22 | 23 | @protocol TJImageCacheDelegate 24 | 25 | - (void)didGetImage:(IMAGE_CLASS *)image atURL:(NSString *)url; 26 | 27 | @optional 28 | 29 | - (void)didFailToGetImageAtURL:(NSString *)url; 30 | 31 | @end 32 | 33 | extern NSString *TJImageCacheHash(NSString *string); 34 | 35 | @interface TJImageCache : NSObject 36 | 37 | + (void)configureWithDefaultRootPath; 38 | + (void)configureWithRootPath:(NSString *const)rootPath; 39 | 40 | + (NSString *)hash:(NSString *)string __attribute__((deprecated("Use TJImageCacheHash instead", "TJImageCacheHash"))); 41 | + (NSString *)pathForURLString:(NSString *const)urlString; 42 | 43 | + (nullable IMAGE_CLASS *)imageAtURL:(NSString *const)url depth:(const TJImageCacheDepth)depth delegate:(nullable const id)delegate backgroundDecode:(const BOOL)backgroundDecode; 44 | + (nullable IMAGE_CLASS *)imageAtURL:(NSString *const)url depth:(const TJImageCacheDepth)depth delegate:(nullable const id)delegate; 45 | + (nullable IMAGE_CLASS *)imageAtURL:(NSString *const)url delegate:(nullable const id)delegate; 46 | + (nullable IMAGE_CLASS *)imageAtURL:(NSString *const)url depth:(const TJImageCacheDepth)depth; 47 | + (nullable IMAGE_CLASS *)imageAtURL:(NSString *const)url; 48 | 49 | + (void)cancelImageLoadForURL:(NSString *const)url delegate:(const id)delegate policy:(const TJImageCacheCancellationPolicy)policy; 50 | 51 | + (TJImageCacheDepth)depthForImageAtURL:(NSString *const)url; 52 | 53 | + (void)removeImageAtURL:(NSString *const)url; 54 | + (void)dumpDiskCache; 55 | + (void)dumpMemoryCache; 56 | + (void)getDiskCacheSize:(void (^const)(long long diskCacheSize))completion; 57 | 58 | + (void)auditCacheWithBlock:(BOOL (^const)(NSString *hashedURL, NSURL *fileURL, long long fileSize))block // return YES to preserve the image, return NO to delete it 59 | propertyKeys:(nullable NSArray *)propertyKeys 60 | completionBlock:(nullable dispatch_block_t)completionBlock; 61 | + (void)auditCacheRemovingFilesLastAccessedBeforeDate:(NSDate *const)date; 62 | 63 | + (void)computeDiskCacheSizeIfNeeded; 64 | /// Will be @c nil until @c +computeDiskCacheSizeIfNeeded, @c +getDiskCacheSize:, or one of the cache auditing methods is called once, then it will update automatically as the cache changes. 65 | /// Observe using KVO. 66 | @property (nonatomic, readonly, class) NSNumber *approximateDiskCacheSize; 67 | 68 | @end 69 | 70 | NS_ASSUME_NONNULL_END 71 | -------------------------------------------------------------------------------- /TJImageCache/TJImageCache.m: -------------------------------------------------------------------------------- 1 | // TJImageCache 2 | // By Tim Johnsen 3 | 4 | #import "TJImageCache.h" 5 | #import 6 | 7 | static NSString *_tj_imageCacheRootPath; 8 | 9 | static NSNumber *_tj_imageCacheBaseSize; 10 | static long long _tj_imageCacheDeltaSize; 11 | static NSNumber *_tj_imageCacheApproximateCacheSize; 12 | 13 | static @interface TJImageCacheNoOpDelegate : NSObject 14 | 15 | @end 16 | 17 | #if defined(__has_attribute) && __has_attribute(objc_direct_members) 18 | __attribute__((objc_direct_members)) 19 | #endif 20 | @implementation TJImageCacheNoOpDelegate 21 | 22 | - (void)didGetImage:(IMAGE_CLASS *)image atURL:(NSString *)url 23 | { 24 | // intentional no-op 25 | } 26 | 27 | @end 28 | 29 | @interface NSHashTable (TJImageCacheAdditions) 30 | 31 | - (BOOL)tj_isEmpty; 32 | 33 | @end 34 | 35 | #if defined(__has_attribute) && __has_attribute(objc_direct_members) 36 | __attribute__((objc_direct_members)) 37 | #endif 38 | @implementation NSHashTable (TJImageCacheAdditions) 39 | 40 | - (BOOL)tj_isEmpty 41 | { 42 | // NSHashTable can sometimes misreport "count" 43 | // This seems to be a surefire way to check if a hash table is truly empty. 44 | // https://stackoverflow.com/a/29882356/3943258 45 | return !self.anyObject; 46 | } 47 | 48 | @end 49 | 50 | #if defined(__has_attribute) && __has_attribute(objc_direct_members) 51 | __attribute__((objc_direct_members)) 52 | #endif 53 | @implementation TJImageCache 54 | 55 | #pragma mark - Configuration 56 | 57 | + (void)configureWithDefaultRootPath 58 | { 59 | [self configureWithRootPath:[[NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject] stringByAppendingPathComponent:@"TJImageCache"]]; 60 | } 61 | 62 | + (void)configureWithRootPath:(NSString *const)rootPath 63 | { 64 | NSParameterAssert(rootPath); 65 | NSAssert(_tj_imageCacheRootPath == nil, @"You should not configure %@'s root path more than once.", NSStringFromClass([self class])); 66 | static dispatch_once_t onceToken; 67 | dispatch_once(&onceToken, ^{ 68 | _tj_imageCacheRootPath = [rootPath copy]; 69 | }); 70 | } 71 | 72 | #pragma mark - Hashing 73 | 74 | + (NSString *)hash:(NSString *)string 75 | { 76 | return TJImageCacheHash(string); 77 | } 78 | 79 | // Using 11 characters from the following table guarantees that we'll generate maximally unique keys that are also tagged pointer strings. 80 | // Tagged pointers have memory and CPU performance benefits, so this is better than just using a plain ol' hex hash. 81 | // I've omitted the "." and " " characters from this table to create "pleasant" filenames. 82 | // For more info see https://mikeash.com/pyblog/friday-qa-2015-07-31-tagged-pointer-strings.html 83 | static char *const kHashCharacterTable = "eilotrmapdnsIcufkMShjTRxgC4013"; 84 | static const NSUInteger kExpectedHashLength = 11; 85 | 86 | NSString *TJImageCacheHash(NSString *string) 87 | { 88 | unsigned char result[CC_SHA256_DIGEST_LENGTH]; 89 | CC_SHA256([string UTF8String], (CC_LONG)string.length, result); 90 | 91 | // Could use sample rejection to reduce bias https://tijo.link/ZU4a6W 92 | return [NSString stringWithFormat:@"%c%c%c%c%c%c%c%c%c%c%c", 93 | kHashCharacterTable[result[0] % 30], 94 | kHashCharacterTable[result[1] % 30], 95 | kHashCharacterTable[result[2] % 30], 96 | kHashCharacterTable[result[3] % 30], 97 | kHashCharacterTable[result[4] % 30], 98 | kHashCharacterTable[result[5] % 30], 99 | kHashCharacterTable[result[6] % 30], 100 | kHashCharacterTable[result[7] % 30], 101 | kHashCharacterTable[result[8] % 30], 102 | kHashCharacterTable[result[9] % 30], 103 | kHashCharacterTable[result[10] % 30] 104 | ]; 105 | } 106 | 107 | + (NSString *)pathForURLString:(NSString *const)urlString 108 | { 109 | return _pathForHash(TJImageCacheHash(urlString)); 110 | } 111 | 112 | #pragma mark - Image Fetching 113 | 114 | + (IMAGE_CLASS *)imageAtURL:(NSString *const)urlString 115 | { 116 | return [self imageAtURL:urlString depth:TJImageCacheDepthNetwork delegate:nil backgroundDecode:YES]; 117 | } 118 | 119 | + (IMAGE_CLASS *)imageAtURL:(NSString *const)urlString depth:(const TJImageCacheDepth)depth 120 | { 121 | return [self imageAtURL:urlString depth:depth delegate:nil backgroundDecode:YES]; 122 | } 123 | 124 | + (IMAGE_CLASS *)imageAtURL:(NSString *const)urlString delegate:(const id)delegate 125 | { 126 | return [self imageAtURL:urlString depth:TJImageCacheDepthNetwork delegate:delegate backgroundDecode:YES]; 127 | } 128 | 129 | + (IMAGE_CLASS *)imageAtURL:(NSString *const)urlString depth:(const TJImageCacheDepth)depth delegate:(nullable const id)delegate 130 | { 131 | return [self imageAtURL:urlString depth:depth delegate:delegate backgroundDecode:YES]; 132 | } 133 | 134 | + (IMAGE_CLASS *)imageAtURL:(NSString *const)urlString depth:(const TJImageCacheDepth)depth delegate:(nullable const id)delegate backgroundDecode:(const BOOL)backgroundDecode 135 | { 136 | if (urlString.length == 0) { 137 | return nil; 138 | } 139 | 140 | // Attempt load from cache. 141 | 142 | __block IMAGE_CLASS *inMemoryImage = [_cache() objectForKey:urlString]; 143 | 144 | // Attempt load from map table. 145 | 146 | if (!inMemoryImage) { 147 | _mapTableWithBlock(^(NSMapTable *const mapTable) { 148 | inMemoryImage = [mapTable objectForKey:urlString]; 149 | }, NO); 150 | if (inMemoryImage) { 151 | // Propagate back into our cache. 152 | [_cache() setObject:inMemoryImage forKey:urlString cost:inMemoryImage.size.width * inMemoryImage.size.height]; 153 | } 154 | } 155 | 156 | // Check if there's an existing disk/network request running for this image. 157 | if (!inMemoryImage && depth != TJImageCacheDepthMemory) { 158 | _requestDelegatesWithBlock(^(NSMutableDictionary> *> *const requestDelegates) { 159 | BOOL loadAsynchronously = NO; 160 | NSHashTable *delegatesForRequest = [requestDelegates objectForKey:urlString]; 161 | if (!delegatesForRequest) { 162 | delegatesForRequest = [NSHashTable weakObjectsHashTable]; 163 | [requestDelegates setObject:delegatesForRequest forKey:urlString]; 164 | loadAsynchronously = YES; 165 | } 166 | if (delegate) { 167 | [delegatesForRequest addObject:delegate]; 168 | } else { 169 | // Since this request was started without a delegate, we add a no-op delegate to ensure that future calls to -cancelImageLoadForURL:delegate: won't inadvertently cancel it. 170 | static TJImageCacheNoOpDelegate *noOpDelegate; 171 | static dispatch_once_t onceToken; 172 | dispatch_once(&onceToken, ^{ 173 | noOpDelegate = [TJImageCacheNoOpDelegate new]; 174 | }); 175 | [delegatesForRequest addObject:noOpDelegate]; 176 | } 177 | 178 | // Attempt load from disk and network. 179 | if (loadAsynchronously) { 180 | static dispatch_queue_t asyncDispatchQueue; 181 | static NSFileManager *fileManager; 182 | static dispatch_once_t readOnceToken; 183 | dispatch_once(&readOnceToken, ^{ 184 | asyncDispatchQueue = dispatch_queue_create("TJImageCache async load queue", DISPATCH_QUEUE_CONCURRENT_WITH_AUTORELEASE_POOL); 185 | fileManager = [NSFileManager defaultManager]; 186 | }); 187 | dispatch_async(asyncDispatchQueue, ^{ 188 | NSString *const hash = TJImageCacheHash(urlString); 189 | NSURL *const url = [NSURL URLWithString:urlString]; 190 | const BOOL isFileURL = url.isFileURL; 191 | NSString *const path = isFileURL ? url.path : _pathForHash(hash); 192 | NSURL *const fileURL = isFileURL ? url : [NSURL fileURLWithPath:path isDirectory:NO]; 193 | if ([fileManager fileExistsAtPath:path]) { 194 | _tryUpdateMemoryCacheAndCallDelegates(path, urlString, hash, backgroundDecode, 0); 195 | 196 | // Update last access date 197 | [fileURL setResourceValue:[NSDate date] forKey:NSURLContentAccessDateKey error:nil]; 198 | } else if (depth == TJImageCacheDepthNetwork && !isFileURL && path) { 199 | static NSURLSession *session; 200 | static dispatch_once_t sessionOnceToken; 201 | dispatch_once(&sessionOnceToken, ^{ 202 | // We use an ephemeral session since TJImageCache does memory and disk caching. 203 | // Using NSURLCache would be redundant. 204 | NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; 205 | config.waitsForConnectivity = YES; 206 | config.timeoutIntervalForResource = 60; 207 | config.HTTPAdditionalHeaders = @{@"Accept": @"image/*"}; 208 | session = [NSURLSession sessionWithConfiguration:config]; 209 | }); 210 | 211 | NSURLSessionDownloadTask *const task = [session downloadTaskWithURL:url completionHandler:^(NSURL *location, NSURLResponse *response, NSError *networkError) { 212 | dispatch_async(asyncDispatchQueue, ^{ 213 | BOOL validToProcess = location != nil && [response isKindOfClass:[NSHTTPURLResponse class]]; 214 | if (validToProcess) { 215 | NSString *contentType; 216 | static NSString *const kContentTypeResponseHeaderKey = @"Content-Type"; 217 | #if !defined(__IPHONE_13_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_13_0 218 | if (@available(iOS 13.0, *)) { 219 | #endif 220 | // -valueForHTTPHeaderField: is more "correct" since it's case-insensitive, however it's only available in iOS 13+. 221 | contentType = [(NSHTTPURLResponse *)response valueForHTTPHeaderField:kContentTypeResponseHeaderKey]; 222 | #if !defined(__IPHONE_13_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_13_0 223 | } else { 224 | contentType = [[(NSHTTPURLResponse *)response allHeaderFields] objectForKey:kContentTypeResponseHeaderKey]; 225 | } 226 | #endif 227 | validToProcess = [contentType hasPrefix:@"image/"]; 228 | } 229 | 230 | BOOL success; 231 | if (validToProcess) { 232 | // Lazily generate the directory the first time it's written to if needed. 233 | static dispatch_once_t rootDirectoryOnceToken; 234 | dispatch_once(&rootDirectoryOnceToken, ^{ 235 | if ([fileManager createDirectoryAtPath:_tj_imageCacheRootPath withIntermediateDirectories:YES attributes:nil error:nil]) { 236 | // Don't back up 237 | // https://developer.apple.com/library/ios/qa/qa1719/_index.html 238 | NSURL *const rootURL = _tj_imageCacheRootPath != nil ? [NSURL fileURLWithPath:_tj_imageCacheRootPath isDirectory:YES] : nil; 239 | [rootURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil]; 240 | } 241 | }); 242 | 243 | // Move resulting image into place. 244 | NSError *error; 245 | if ([fileManager moveItemAtURL:location toURL:fileURL error:&error]) { 246 | success = YES; 247 | } else { 248 | // Still consider this a success if the file already exists. 249 | success = error.code == NSFileWriteFileExistsError // https://apple.co/3vO2s0X 250 | && [error.domain isEqualToString:NSCocoaErrorDomain]; 251 | NSAssert(!success, @"Loaded file that already exists! %@ -> %@", urlString, hash); 252 | } 253 | } else { 254 | success = NO; 255 | } 256 | 257 | if (success) { 258 | // Inform delegates about success 259 | _tryUpdateMemoryCacheAndCallDelegates(path, urlString, hash, backgroundDecode, response.expectedContentLength); 260 | } else { 261 | // Inform delegates about failure 262 | _tryUpdateMemoryCacheAndCallDelegates(nil, urlString, hash, backgroundDecode, 0); 263 | if (location) { 264 | [fileManager removeItemAtURL:location error:nil]; 265 | } 266 | } 267 | 268 | _tasksForImageURLStringsWithBlock(^(NSMutableDictionary *const tasks) { 269 | [tasks removeObjectForKey:urlString]; 270 | }); 271 | }); 272 | }]; 273 | 274 | task.countOfBytesClientExpectsToSend = 0; 275 | 276 | _tasksForImageURLStringsWithBlock(^(NSMutableDictionary *const tasks) { 277 | [tasks setObject:task forKey:urlString]; 278 | }); 279 | 280 | [task resume]; 281 | } else { 282 | // Inform delegates about failure 283 | _tryUpdateMemoryCacheAndCallDelegates(nil, urlString, hash, backgroundDecode, 0); 284 | } 285 | }); 286 | } 287 | }, NO); 288 | } 289 | 290 | return inMemoryImage; 291 | } 292 | 293 | + (void)cancelImageLoadForURL:(NSString *const)urlString delegate:(const id)delegate policy:(const TJImageCacheCancellationPolicy)policy 294 | { 295 | _requestDelegatesWithBlock(^(NSMutableDictionary> *> *const requestDelegates) { 296 | BOOL cancelTask = NO; 297 | NSHashTable *const delegates = [requestDelegates objectForKey:urlString]; 298 | if (delegates) { 299 | [delegates removeObject:delegate]; 300 | if ([delegates tj_isEmpty]) { 301 | cancelTask = YES; 302 | } 303 | } 304 | if (cancelTask && policy != TJImageCacheCancellationPolicyImageProcessing) { 305 | // NOTE: Could potentially use -getTasksWithCompletionHandler: instead, however that's async. 306 | _tasksForImageURLStringsWithBlock(^(NSMutableDictionary *const tasks) { 307 | NSURLSessionTask *const task = tasks[urlString]; 308 | if (task) { 309 | switch (policy) { 310 | case TJImageCacheCancellationPolicyBeforeResponse: 311 | if (task.response) { 312 | break; 313 | } 314 | case TJImageCacheCancellationPolicyBeforeBody: 315 | if (task.countOfBytesReceived > 0) { 316 | break; 317 | } 318 | case TJImageCacheCancellationPolicyUnconditional: 319 | [task cancel]; 320 | [requestDelegates removeObjectForKey:urlString]; 321 | break; 322 | case TJImageCacheCancellationPolicyImageProcessing: 323 | NSAssert(NO, @"This should never be reached"); 324 | break; 325 | } 326 | } 327 | }); 328 | } 329 | }, NO); 330 | } 331 | 332 | #pragma mark - Cache Checking 333 | 334 | + (TJImageCacheDepth)depthForImageAtURL:(NSString *const)urlString 335 | { 336 | if ([_cache() objectForKey:urlString]) { 337 | return TJImageCacheDepthMemory; 338 | } 339 | 340 | __block BOOL isImageInMapTable = NO; 341 | _mapTableWithBlock(^(NSMapTable *const mapTable) { 342 | isImageInMapTable = [mapTable objectForKey:urlString] != nil; 343 | }, NO); 344 | 345 | if (isImageInMapTable) { 346 | return TJImageCacheDepthMemory; 347 | } 348 | 349 | NSString *const hash = TJImageCacheHash(urlString); 350 | if ([[NSFileManager defaultManager] fileExistsAtPath:_pathForHash(hash)]) { 351 | return TJImageCacheDepthDisk; 352 | } 353 | 354 | return TJImageCacheDepthNetwork; 355 | } 356 | 357 | + (void)getDiskCacheSize:(void (^const)(long long diskCacheSize))completion 358 | { 359 | dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ 360 | long long fileSize = 0; 361 | NSDirectoryEnumerator *const enumerator = [[NSFileManager defaultManager] enumeratorAtURL:[NSURL fileURLWithPath:_rootPath() isDirectory:YES] includingPropertiesForKeys:@[NSURLTotalFileAllocatedSizeKey] options:0 errorHandler:nil]; 362 | for (NSURL *url in enumerator) { 363 | NSNumber *fileSizeNumber; 364 | [url getResourceValue:&fileSizeNumber forKey:NSURLTotalFileAllocatedSizeKey error:nil]; 365 | fileSize += fileSizeNumber.unsignedLongLongValue; 366 | } 367 | dispatch_async(dispatch_get_main_queue(), ^{ 368 | completion(fileSize); 369 | _setBaseCacheSize(fileSize); 370 | }); 371 | }); 372 | } 373 | 374 | #pragma mark - Cache Manipulation 375 | 376 | + (void)removeImageAtURL:(NSString *const)urlString 377 | { 378 | [_cache() removeObjectForKey:urlString]; 379 | NSString *const path = _pathForHash(TJImageCacheHash(urlString)); 380 | NSNumber *fileSizeNumber; 381 | [[NSURL fileURLWithPath:path] getResourceValue:&fileSizeNumber forKey:NSURLTotalFileSizeKey error:nil]; 382 | if ([[NSFileManager defaultManager] removeItemAtPath:path error:nil]) { 383 | _modifyDeltaSize(-fileSizeNumber.longLongValue); 384 | } 385 | } 386 | 387 | + (void)dumpMemoryCache 388 | { 389 | [_cache() removeAllObjects]; 390 | } 391 | 392 | + (void)dumpDiskCache 393 | { 394 | [self auditCacheWithBlock:^BOOL(NSString *hashedURL, NSURL *fileURL, long long fileSize) { 395 | return NO; 396 | } 397 | propertyKeys:nil 398 | completionBlock:nil]; 399 | } 400 | 401 | #pragma mark - Cache Auditing 402 | 403 | + (void)auditCacheWithBlock:(BOOL (^const)(NSString *hashedURL, NSURL *fileURL, long long fileSize))block 404 | propertyKeys:(NSArray *const)inPropertyKeys 405 | completionBlock:(const dispatch_block_t)completionBlock 406 | { 407 | dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0), ^{ 408 | NSFileManager *const fileManager = [NSFileManager defaultManager]; 409 | NSArray *const propertyKeys = inPropertyKeys ? [inPropertyKeys arrayByAddingObject:NSURLTotalFileAllocatedSizeKey] : @[NSURLTotalFileAllocatedSizeKey]; 410 | NSDirectoryEnumerator *const enumerator = [fileManager enumeratorAtURL:[NSURL fileURLWithPath:_rootPath() isDirectory:NO] 411 | includingPropertiesForKeys:propertyKeys 412 | options:0 413 | errorHandler:nil]; 414 | long long totalFileSize = 0; 415 | for (NSURL *url in enumerator) { 416 | @autoreleasepool { 417 | NSNumber *fileSizeNumber; 418 | [url getResourceValue:&fileSizeNumber forKey:NSURLTotalFileAllocatedSizeKey error:nil]; 419 | const unsigned long long fileSize = fileSizeNumber.unsignedLongValue; 420 | BOOL remove; 421 | NSString *const file = url.lastPathComponent; 422 | if (file.length == kExpectedHashLength) { 423 | __block BOOL isInUse = NO; 424 | _mapTableWithBlock(^(NSMapTable *const mapTable) { 425 | isInUse = [mapTable objectForKey:file] != nil; 426 | }, NO); 427 | remove = !isInUse && !block(file, url, fileSize); 428 | } else { 429 | remove = YES; 430 | } 431 | BOOL wasRemoved; 432 | if (remove) { 433 | wasRemoved = [fileManager removeItemAtPath:_pathForHash(file) error:nil]; 434 | } else { 435 | wasRemoved = NO; 436 | } 437 | if (!wasRemoved) { 438 | totalFileSize += fileSize; 439 | } 440 | } 441 | } 442 | dispatch_async(dispatch_get_main_queue(), ^{ 443 | if (completionBlock) { 444 | completionBlock(); 445 | } 446 | _setBaseCacheSize(totalFileSize); 447 | }); 448 | }); 449 | } 450 | 451 | + (void)auditCacheRemovingFilesLastAccessedBeforeDate:(NSDate *const)date 452 | { 453 | [self auditCacheWithBlock:^BOOL(NSString *hashedURL, NSURL *fileURL, long long fileSize) { 454 | NSDate *lastAccess; 455 | [fileURL getResourceValue:&lastAccess forKey:NSURLContentAccessDateKey error:nil]; 456 | return ([lastAccess compare:date] != NSOrderedAscending); 457 | } 458 | propertyKeys:@[NSURLContentAccessDateKey] 459 | completionBlock:nil]; 460 | } 461 | 462 | #pragma mark - Private 463 | 464 | static NSString *_rootPath(void) 465 | { 466 | NSCAssert(_tj_imageCacheRootPath != nil, @"You should configure %@'s root path before attempting to use it.", NSStringFromClass([TJImageCache class])); 467 | return _tj_imageCacheRootPath; 468 | } 469 | 470 | static NSString *_pathForHash(NSString *const hash) 471 | { 472 | NSString *path = _rootPath(); 473 | if (hash) { 474 | path = [path stringByAppendingPathComponent:hash]; 475 | } 476 | return path; 477 | } 478 | 479 | /// Keys are image URL strings, NOT hashes 480 | static NSCache *_cache(void) 481 | { 482 | static NSCache *cache; 483 | static dispatch_once_t token; 484 | 485 | dispatch_once(&token, ^{ 486 | cache = [NSCache new]; 487 | }); 488 | 489 | return cache; 490 | } 491 | 492 | /// Every image maps to two keys in this map table. 493 | /// { image URL string -> image, 494 | /// image URL string hash -> image } 495 | /// Both keys are used so that we can easily query for membership based on either URL (used for in-memory lookups) or hash (used for on-disk lookups) 496 | static void _mapTableWithBlock(void (^block)(NSMapTable *const mapTable), const BOOL blockIsWriteOnly) 497 | { 498 | static NSMapTable *mapTable; 499 | static dispatch_once_t token; 500 | static dispatch_queue_t queue; 501 | 502 | dispatch_once(&token, ^{ 503 | mapTable = [NSMapTable strongToWeakObjectsMapTable]; 504 | queue = dispatch_queue_create("TJImageCache map table queue", DISPATCH_QUEUE_CONCURRENT); 505 | }); 506 | 507 | if (blockIsWriteOnly) { 508 | dispatch_barrier_async(queue, ^{ 509 | block(mapTable); 510 | }); 511 | } else { 512 | dispatch_sync(queue, ^{ 513 | block(mapTable); 514 | }); 515 | } 516 | } 517 | 518 | /// Keys are image URL strings 519 | static void _requestDelegatesWithBlock(void (^block)(NSMutableDictionary> *> *const requestDelegates), const BOOL sync) 520 | { 521 | static NSMutableDictionary> *> *requests; 522 | static dispatch_once_t token; 523 | static dispatch_queue_t queue; 524 | 525 | dispatch_once(&token, ^{ 526 | requests = [NSMutableDictionary new]; 527 | queue = dispatch_queue_create("TJImageCache._requestDelegatesWithBlock", DISPATCH_QUEUE_SERIAL); 528 | }); 529 | 530 | if (sync) { 531 | dispatch_sync(queue, ^{ 532 | block(requests); 533 | }); 534 | } else { 535 | dispatch_async(queue, ^{ 536 | block(requests); 537 | }); 538 | } 539 | } 540 | 541 | /// Keys are image URL strings 542 | static void _tasksForImageURLStringsWithBlock(void (^block)(NSMutableDictionary *const tasks)) 543 | { 544 | static NSMutableDictionary *tasks; 545 | static dispatch_once_t token; 546 | static dispatch_queue_t queue; 547 | 548 | dispatch_once(&token, ^{ 549 | tasks = [NSMutableDictionary new]; 550 | queue = dispatch_queue_create("TJImageCache._tasksForImageURLStringsWithBlock", DISPATCH_QUEUE_SERIAL); 551 | }); 552 | 553 | dispatch_sync(queue, ^{ 554 | block(tasks); 555 | }); 556 | } 557 | 558 | static void _tryUpdateMemoryCacheAndCallDelegates(NSString *const path, NSString *const urlString, NSString *const hash, const BOOL backgroundDecode, const long long size) 559 | { 560 | __block NSHashTable *delegatesForRequest = nil; 561 | _requestDelegatesWithBlock(^(NSMutableDictionary> *> *const requestDelegates) { 562 | delegatesForRequest = [requestDelegates objectForKey:urlString]; 563 | [requestDelegates removeObjectForKey:urlString]; 564 | }, YES); 565 | 566 | const BOOL canProcess = ![delegatesForRequest tj_isEmpty]; 567 | 568 | IMAGE_CLASS *image = nil; 569 | if (canProcess) { 570 | if (path) { 571 | if (backgroundDecode) { 572 | image = _predrawnImageFromPath(path); 573 | } 574 | if (!image) { 575 | image = [IMAGE_CLASS imageWithContentsOfFile:path]; 576 | } 577 | } 578 | if (image) { 579 | [_cache() setObject:image forKey:urlString cost:image.size.width * image.size.height]; 580 | _mapTableWithBlock(^(NSMapTable *const mapTable) { 581 | [mapTable setObject:image forKey:hash]; 582 | [mapTable setObject:image forKey:urlString]; 583 | }, YES); 584 | } 585 | } 586 | // else { Skip drawing / updating cache / calling delegates since the result wouldn't be used } 587 | 588 | dispatch_async(dispatch_get_main_queue(), ^{ 589 | for (id delegate in delegatesForRequest) { 590 | if (image) { 591 | [delegate didGetImage:image atURL:urlString]; 592 | } else if ([delegate respondsToSelector:@selector(didFailToGetImageAtURL:)]) { 593 | [delegate didFailToGetImageAtURL:urlString]; 594 | } 595 | } 596 | _modifyDeltaSize(size); 597 | }); 598 | 599 | // Per this WWDC talk, dump as much memory as possible when entering the background to avoid jetsam. 600 | // https://developer.apple.com/videos/play/wwdc2020/10078/?t=333 601 | static dispatch_once_t onceToken; 602 | dispatch_once(&onceToken, ^{ 603 | void (^emptyCacheBlock)(NSNotification *) = ^(NSNotification * _Nonnull note) { 604 | [TJImageCache dumpMemoryCache]; 605 | }; 606 | [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:nil usingBlock:emptyCacheBlock]; 607 | [[NSNotificationCenter defaultCenter] addObserverForName:NSExtensionHostDidEnterBackgroundNotification object:nil queue:nil usingBlock:emptyCacheBlock]; 608 | }); 609 | } 610 | 611 | // Modified version of https://github.com/Flipboard/FLAnimatedImage/blob/master/FLAnimatedImageDemo/FLAnimatedImage/FLAnimatedImage.m#L641 612 | static IMAGE_CLASS *_predrawnImageFromPath(NSString *const path) 613 | { 614 | #if defined(__IPHONE_15_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_15_0 615 | #if !defined(__IPHONE_15_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_15_0 616 | if (@available(iOS 15.0, *)) 617 | #endif 618 | { 619 | return [[UIImage imageWithContentsOfFile:path] imageByPreparingForDisplay]; 620 | } 621 | #endif 622 | 623 | #if !defined(__IPHONE_15_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_15_0 624 | // Always use a device RGB color space for simplicity and predictability what will be going on. 625 | static CGColorSpaceRef colorSpaceDeviceRGBRef; 626 | static CFDictionaryRef options; 627 | static dispatch_once_t onceToken; 628 | dispatch_once(&onceToken, ^{ 629 | colorSpaceDeviceRGBRef = CGColorSpaceCreateDeviceRGB(); 630 | options = (__bridge_retained CFDictionaryRef)@{(__bridge NSString *)kCGImageSourceShouldCache: (__bridge id)kCFBooleanFalse}; 631 | }); 632 | 633 | const CGImageSourceRef imageSource = CGImageSourceCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:path isDirectory:NO], nil); 634 | const CGImageRef image = CGImageSourceCreateImageAtIndex(imageSource, 0, options); 635 | 636 | if (imageSource) { 637 | CFRelease(imageSource); 638 | } 639 | 640 | if (!image) { 641 | return nil; 642 | } 643 | 644 | // "In iOS 4.0 and later, and OS X v10.6 and later, you can pass NULL if you want Quartz to allocate memory for the bitmap." (source: docs) 645 | const size_t width = CGImageGetWidth(image); 646 | const size_t height = CGImageGetHeight(image); 647 | 648 | // RGB+A 649 | const size_t bytesPerRow = width << 2; 650 | 651 | CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(image); 652 | // If the alpha info doesn't match to one of the supported formats (see above), pick a reasonable supported one. 653 | // "For bitmaps created in iOS 3.2 and later, the drawing environment uses the premultiplied ARGB format to store the bitmap data." (source: docs) 654 | switch (alphaInfo) { 655 | case kCGImageAlphaNone: 656 | case kCGImageAlphaOnly: 657 | case kCGImageAlphaFirst: 658 | alphaInfo = kCGImageAlphaNoneSkipFirst; 659 | break; 660 | case kCGImageAlphaLast: 661 | alphaInfo = kCGImageAlphaNoneSkipLast; 662 | break; 663 | default: 664 | break; 665 | } 666 | 667 | // Create our own graphics context to draw to; `UIGraphicsGetCurrentContext`/`UIGraphicsBeginImageContextWithOptions` doesn't create a new context but returns the current one which isn't thread-safe (e.g. main thread could use it at the same time). 668 | // Note: It's not worth caching the bitmap context for multiple frames ("unique key" would be `width`, `height` and `hasAlpha`), it's ~50% slower. Time spent in libRIP's `CGSBlendBGRA8888toARGB8888` suddenly shoots up -- not sure why. 669 | 670 | const CGContextRef bitmapContextRef = CGBitmapContextCreate(NULL, width, height, CHAR_BIT, bytesPerRow, colorSpaceDeviceRGBRef, kCGBitmapByteOrderDefault | alphaInfo); 671 | // Early return on failure! 672 | if (!bitmapContextRef) { 673 | NSCAssert(NO, @"Failed to `CGBitmapContextCreate` with color space %@ and parameters (width: %zu height: %zu bitsPerComponent: %zu bytesPerRow: %zu) for image %@", colorSpaceDeviceRGBRef, width, height, (size_t)CHAR_BIT, bytesPerRow, image); 674 | CGImageRelease(image); 675 | return nil; 676 | } 677 | 678 | // Draw image in bitmap context and create image by preserving receiver's properties. 679 | CGContextDrawImage(bitmapContextRef, CGRectMake(0.0, 0.0, width, height), image); 680 | const CGImageRef predrawnImageRef = CGBitmapContextCreateImage(bitmapContextRef); 681 | IMAGE_CLASS *const predrawnImage = [IMAGE_CLASS imageWithCGImage:predrawnImageRef]; 682 | CGImageRelease(image); 683 | CGImageRelease(predrawnImageRef); 684 | CGContextRelease(bitmapContextRef); 685 | 686 | return predrawnImage; 687 | #endif 688 | } 689 | 690 | + (void)computeDiskCacheSizeIfNeeded 691 | { 692 | if (_tj_imageCacheBaseSize == nil) { 693 | [self getDiskCacheSize:^(long long diskCacheSize) { 694 | // intentional no-op, cache size is set as a side effect of +getDiskCacheSize: running. 695 | }]; 696 | } 697 | } 698 | 699 | + (NSNumber *)approximateDiskCacheSize 700 | { 701 | return _tj_imageCacheApproximateCacheSize; 702 | } 703 | 704 | static void _setApproximateCacheSize(const long long cacheSize) 705 | { 706 | static NSString *key; 707 | static dispatch_once_t onceToken; 708 | dispatch_once(&onceToken, ^{ 709 | key = NSStringFromSelector(@selector(approximateDiskCacheSize)); 710 | }); 711 | if (cacheSize != _tj_imageCacheApproximateCacheSize.longLongValue) { 712 | [TJImageCache willChangeValueForKey:key]; 713 | _tj_imageCacheApproximateCacheSize = @(cacheSize); 714 | [TJImageCache didChangeValueForKey:key]; 715 | } 716 | } 717 | 718 | static void _setBaseCacheSize(const long long diskCacheSize) 719 | { 720 | _tj_imageCacheBaseSize = @(diskCacheSize); 721 | _tj_imageCacheDeltaSize = 0; 722 | _setApproximateCacheSize(diskCacheSize); 723 | } 724 | 725 | static void _modifyDeltaSize(const long long delta) 726 | { 727 | // We don't track in-memory deltas unless a base size has been computed. 728 | if (_tj_imageCacheBaseSize != nil) { 729 | _tj_imageCacheDeltaSize += delta; 730 | _setApproximateCacheSize(_tj_imageCacheBaseSize.longLongValue + _tj_imageCacheDeltaSize); 731 | } 732 | } 733 | 734 | @end 735 | -------------------------------------------------------------------------------- /TJImageCache/TJImageView.h: -------------------------------------------------------------------------------- 1 | // TJImageView 2 | // By Tim Johnsen 3 | 4 | #import 5 | 6 | extern const NSTimeInterval kTJImageViewDefaultImageAppearanceAnimationDuration; 7 | 8 | @interface TJImageView : UIView 9 | 10 | @property (nonatomic, copy) NSString *imageURLString; 11 | @property (nonatomic, strong, readonly) UIImageView *imageView; 12 | 13 | @property (nonatomic, assign) NSTimeInterval imageAppearanceAnimationDuration; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /TJImageCache/TJImageView.m: -------------------------------------------------------------------------------- 1 | // TJImageView 2 | // By Tim Johnsen 3 | 4 | #import "TJImageView.h" 5 | #import "TJImageCache.h" 6 | 7 | const NSTimeInterval kTJImageViewDefaultImageAppearanceAnimationDuration = 0.25; 8 | 9 | @interface TJImageView () 10 | 11 | @property (nonatomic, strong, readwrite) UIImageView *imageView; 12 | 13 | @end 14 | 15 | @implementation TJImageView 16 | 17 | #pragma mark - UIView 18 | 19 | - (instancetype)initWithFrame:(CGRect)frame 20 | { 21 | self = [super initWithFrame:frame]; 22 | if (self) { 23 | self.imageView = [[UIImageView alloc] initWithFrame:self.bounds]; 24 | self.imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 25 | [self addSubview:self.imageView]; 26 | 27 | // Defaults 28 | self.backgroundColor = [UIColor blackColor]; 29 | self.imageView.contentMode = UIViewContentModeScaleAspectFill; 30 | self.imageView.opaque = YES; 31 | self.clipsToBounds = YES; 32 | self.imageAppearanceAnimationDuration = kTJImageViewDefaultImageAppearanceAnimationDuration; 33 | } 34 | return self; 35 | } 36 | 37 | #pragma mark - Properties 38 | 39 | - (void)setImageURLString:(NSString *)imageURLString 40 | { 41 | if (imageURLString != _imageURLString && ![imageURLString isEqualToString:_imageURLString]) { 42 | _imageURLString = [imageURLString copy]; 43 | 44 | self.imageView.image = [TJImageCache imageAtURL:imageURLString delegate:self]; 45 | self.imageView.alpha = (self.imageView.image != nil) ? 1.0 : 0.0; 46 | } 47 | } 48 | 49 | #pragma mark - TJImageCacheDelegate 50 | 51 | - (void)didGetImage:(UIImage *)image atURL:(NSString *)url 52 | { 53 | if ([url isEqualToString:self.imageURLString] && !self.imageView.image) { 54 | self.imageView.image = image; 55 | [UIView animateWithDuration:self.imageAppearanceAnimationDuration delay:0.0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionAllowUserInteraction animations:^{ 56 | self.imageView.alpha = 1.0; 57 | } completion:nil]; 58 | } 59 | } 60 | 61 | @end 62 | -------------------------------------------------------------------------------- /TJImageCache/TJProgressiveImageView.h: -------------------------------------------------------------------------------- 1 | // 2 | // TJProgressiveImageView.h 3 | // OpenerCore 4 | // 5 | // Created by Tim Johnsen on 1/30/20. 6 | // Copyright © 2020 tijo. All rights reserved. 7 | // 8 | 9 | #import "TJImageCache.h" 10 | #import 11 | 12 | NS_ASSUME_NONNULL_BEGIN 13 | 14 | @interface TJProgressiveImageView : UIImageView 15 | 16 | @property (nonatomic, nullable) NSOrderedSet *imageURLStrings; 17 | 18 | - (void)setImageURLStrings:(NSOrderedSet * _Nullable)imageURLStrings secondaryImageDepth:(const TJImageCacheDepth)secondaryImageDepth; 19 | 20 | - (void)cancelImageLoadsWithPolicy:(const TJImageCacheCancellationPolicy)policy; 21 | 22 | @end 23 | 24 | NS_ASSUME_NONNULL_END 25 | -------------------------------------------------------------------------------- /TJImageCache/TJProgressiveImageView.m: -------------------------------------------------------------------------------- 1 | // 2 | // TJProgressiveImageView.m 3 | // OpenerCore 4 | // 5 | // Created by Tim Johnsen on 1/30/20. 6 | // Copyright © 2020 tijo. All rights reserved. 7 | // 8 | 9 | #import "TJProgressiveImageView.h" 10 | 11 | __attribute__((objc_direct_members)) 12 | @interface TJProgressiveImageView () { 13 | NSInteger _currentImageURLStringIndex; 14 | } 15 | 16 | @end 17 | 18 | __attribute__((objc_direct_members)) 19 | @implementation TJProgressiveImageView 20 | 21 | - (instancetype)initWithFrame:(CGRect)frame 22 | { 23 | if (self = [super initWithFrame:frame]) { 24 | _currentImageURLStringIndex = NSNotFound; 25 | } 26 | return self; 27 | } 28 | 29 | - (void)setImageURLStrings:(NSOrderedSet *)imageURLStrings 30 | { 31 | [self setImageURLStrings:imageURLStrings secondaryImageDepth:TJImageCacheDepthDisk]; 32 | } 33 | 34 | - (void)setImageURLStrings:(NSOrderedSet * _Nullable)imageURLStrings secondaryImageDepth:(const TJImageCacheDepth)secondaryImageDepth 35 | { 36 | if (imageURLStrings != _imageURLStrings && ![imageURLStrings isEqual:_imageURLStrings]) { 37 | NSOrderedSet *const priorImageURLStrings = _imageURLStrings; 38 | NSString *const priorImageURLString = _currentImageURLStringIndex != NSNotFound ? [_imageURLStrings objectAtIndex:_currentImageURLStringIndex] : nil; 39 | _imageURLStrings = imageURLStrings; 40 | _currentImageURLStringIndex = _imageURLStrings == nil || priorImageURLString == nil ? NSNotFound : [_imageURLStrings indexOfObject:priorImageURLString]; 41 | 42 | if (_currentImageURLStringIndex != 0) { 43 | [_imageURLStrings enumerateObjectsUsingBlock:^(NSString * _Nonnull urlString, NSUInteger idx, BOOL * _Nonnull stop) { 44 | if (idx >= _currentImageURLStringIndex) { 45 | // Don't attempt to load images beyond the best one we already have. 46 | *stop = YES; 47 | } else { 48 | // Load image 0 from network, all others loaded at secondaryImageDepth. 49 | const TJImageCacheDepth depth = idx == 0 ? TJImageCacheDepthNetwork : secondaryImageDepth; 50 | UIImage *const image = [TJImageCache imageAtURL:urlString depth:depth delegate:self backgroundDecode:YES]; 51 | if (image) { 52 | _currentImageURLStringIndex = idx; 53 | self.image = image; 54 | *stop = YES; 55 | } 56 | } 57 | }]; 58 | 59 | if (_currentImageURLStringIndex == NSNotFound) { 60 | self.image = nil; 61 | } 62 | } 63 | 64 | for (NSString *str in priorImageURLStrings) { 65 | if (![imageURLStrings containsObject:str]) { 66 | [TJImageCache cancelImageLoadForURL:str delegate:self policy:TJImageCacheCancellationPolicyImageProcessing]; 67 | } 68 | } 69 | } 70 | } 71 | 72 | - (void)cancelImageLoadsWithPolicy:(const TJImageCacheCancellationPolicy)policy 73 | { 74 | [self.imageURLStrings enumerateObjectsUsingBlock:^(NSString * _Nonnull urlString, NSUInteger idx, BOOL * _Nonnull stop) { 75 | if (idx < _currentImageURLStringIndex || _currentImageURLStringIndex == NSNotFound) { 76 | [TJImageCache cancelImageLoadForURL:urlString delegate:self policy:policy]; 77 | } else { 78 | *stop = YES; 79 | } 80 | }]; 81 | } 82 | 83 | #pragma mark - TJImageCacheDelegate 84 | 85 | - (void)didGetImage:(UIImage *)image atURL:(NSString *)url 86 | { 87 | __block BOOL cancelLowPriImages = NO; 88 | [_imageURLStrings enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { 89 | if (idx < _currentImageURLStringIndex) { 90 | if ([obj isEqualToString:url]) { 91 | _currentImageURLStringIndex = idx; 92 | self.image = image; 93 | cancelLowPriImages = YES; 94 | } 95 | } else if (cancelLowPriImages) { 96 | // Cancel any lower priority images 97 | [TJImageCache cancelImageLoadForURL:obj delegate:self policy:TJImageCacheCancellationPolicyImageProcessing]; 98 | } else { 99 | *stop = YES; 100 | } 101 | }]; 102 | } 103 | 104 | @end 105 | -------------------------------------------------------------------------------- /TJImageCache/UIImageView+TJImageCache.h: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView+TJImageCache.h 3 | // OpenerCore 4 | // 5 | // Created by Tim Johnsen on 2/10/18. 6 | // Copyright © 2018 tijo. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #import "TJImageCache.h" 12 | 13 | NS_ASSUME_NONNULL_BEGIN 14 | 15 | __attribute__((objc_direct_members)) 16 | @interface UIImageView (TJImageCache) 17 | 18 | @property (nonatomic, copy, nullable, setter=tj_setImageURLString:) NSString *tj_imageURLString; 19 | 20 | - (void)tj_setImageURLString:(nullable NSString *const)imageURLString backgroundDecode:(const BOOL)backgroundDecode; 21 | - (void)tj_setImageURLString:(nullable NSString *const)imageURLString depth:(const TJImageCacheDepth)depth backgroundDecode:(const BOOL)backgroundDecode; 22 | 23 | - (void)tj_cancelImageLoadWithPolicy:(const TJImageCacheCancellationPolicy)policy; 24 | 25 | @end 26 | 27 | NS_ASSUME_NONNULL_END 28 | -------------------------------------------------------------------------------- /TJImageCache/UIImageView+TJImageCache.m: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView+TJImageCache.m 3 | // OpenerCore 4 | // 5 | // Created by Tim Johnsen on 2/10/18. 6 | // Copyright © 2018 tijo. All rights reserved. 7 | // 8 | 9 | #import "UIImageView+TJImageCache.h" 10 | 11 | #import 12 | 13 | static char *const kTJImageCacheUIImageViewImageURLStringKey = "tj_imageURLString"; 14 | 15 | __attribute__((objc_direct_members)) 16 | @interface UIImageView (TJImageCachePrivate) 17 | 18 | @end 19 | 20 | @implementation UIImageView (TJImageCachePrivate) 21 | 22 | #pragma mark - TJImageCacheDelegate 23 | 24 | - (void)didGetImage:(UIImage *)image atURL:(NSString *)url 25 | { 26 | if ([url isEqualToString:self.tj_imageURLString]) { 27 | self.image = image; 28 | } 29 | } 30 | 31 | @end 32 | 33 | @implementation UIImageView (TJImageCache) 34 | 35 | #pragma mark - Getters and Setters 36 | 37 | - (void)tj_setImageURLString:(NSString *)imageURLString 38 | { 39 | [self tj_setImageURLString:imageURLString depth:TJImageCacheDepthNetwork backgroundDecode:YES]; 40 | } 41 | 42 | - (void)tj_setImageURLString:(NSString *const)imageURLString backgroundDecode:(const BOOL)backgroundDecode 43 | { 44 | [self tj_setImageURLString:imageURLString depth:TJImageCacheDepthNetwork backgroundDecode:backgroundDecode]; 45 | } 46 | 47 | - (void)tj_setImageURLString:(nullable NSString *const)imageURLString depth:(const TJImageCacheDepth)depth backgroundDecode:(const BOOL)backgroundDecode 48 | { 49 | NSString *const currentImageURLString = self.tj_imageURLString; 50 | if (imageURLString != currentImageURLString && ![imageURLString isEqualToString:currentImageURLString]) { 51 | self.image = [TJImageCache imageAtURL:imageURLString depth:TJImageCacheDepthNetwork delegate:self backgroundDecode:backgroundDecode]; 52 | objc_setAssociatedObject(self, kTJImageCacheUIImageViewImageURLStringKey, imageURLString, OBJC_ASSOCIATION_COPY_NONATOMIC); 53 | if (currentImageURLString) { 54 | [TJImageCache cancelImageLoadForURL:currentImageURLString delegate:self policy:TJImageCacheCancellationPolicyImageProcessing]; 55 | } 56 | } 57 | } 58 | 59 | - (NSString *)tj_imageURLString 60 | { 61 | return objc_getAssociatedObject(self, kTJImageCacheUIImageViewImageURLStringKey); 62 | } 63 | 64 | - (void)tj_cancelImageLoadWithPolicy:(const TJImageCacheCancellationPolicy)policy 65 | { 66 | if (!self.image) { 67 | NSString *urlString = [self tj_imageURLString]; 68 | if (urlString) { 69 | [TJImageCache cancelImageLoadForURL:urlString delegate:self policy:policy]; 70 | } 71 | } 72 | } 73 | 74 | @end 75 | --------------------------------------------------------------------------------