├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Development ├── AsyncMultiplexImage-Demo.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── AsyncMultiplexImage-Demo.xcscheme └── AsyncMultiplexImage-Demo │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── AsyncMultiplexImage_Demo.entitlements │ ├── ContentView.swift │ ├── List.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── ShrinkDemo.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── AsyncMultiplexImage-Nuke │ ├── AsyncMultiplexImageNuke.swift │ └── AsyncMultiplexImageNukeDownloader.swift └── AsyncMultiplexImage │ ├── AsyncMultiplexImage.swift │ ├── AsyncMultiplexImageContent.swift │ ├── AsyncMultiplexImageView.swift │ ├── DownloadManager.swift │ └── MultiplexImage.swift └── Tests └── AsyncMultiplexImageTests └── AsyncMultiplexImageTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | Derived 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 4B094D622D65B82500E4C4A9 /* ShrinkDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B094D612D65B82000E4C4A9 /* ShrinkDemo.swift */; }; 11 | 4B57105B28D0AC7F00AA053C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B57105A28D0AC7F00AA053C /* ContentView.swift */; }; 12 | 4B57105D28D0AC8000AA053C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B57105C28D0AC8000AA053C /* Assets.xcassets */; }; 13 | 4B57106128D0AC8000AA053C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B57106028D0AC8000AA053C /* Preview Assets.xcassets */; }; 14 | 4B57106A28D0ACA300AA053C /* AsyncMultiplexImage in Frameworks */ = {isa = PBXBuildFile; productRef = 4B57106928D0ACA300AA053C /* AsyncMultiplexImage */; }; 15 | 4B57106C28D0ACA800AA053C /* AsyncMultiplexImage-Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 4B57106B28D0ACA800AA053C /* AsyncMultiplexImage-Nuke */; }; 16 | 4B5AC66A2BB96A8D00641C3B /* SwiftUIHosting in Frameworks */ = {isa = PBXBuildFile; productRef = 4B5AC6692BB96A8D00641C3B /* SwiftUIHosting */; }; 17 | 4B5AC66D2BB96AAB00641C3B /* MondrianLayout in Frameworks */ = {isa = PBXBuildFile; productRef = 4B5AC66C2BB96AAB00641C3B /* MondrianLayout */; }; 18 | 4B7E24F9296184E300E53388 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7E24F8296184E300E53388 /* AppDelegate.swift */; }; 19 | 4BB403CD2C19F9A80033B5E7 /* List.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB403CC2C19F9A80033B5E7 /* List.swift */; }; 20 | /* End PBXBuildFile section */ 21 | 22 | /* Begin PBXFileReference section */ 23 | 4B094D612D65B82000E4C4A9 /* ShrinkDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShrinkDemo.swift; sourceTree = ""; }; 24 | 4B57105528D0AC7F00AA053C /* AsyncMultiplexImage-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AsyncMultiplexImage-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 25 | 4B57105A28D0AC7F00AA053C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 26 | 4B57105C28D0AC8000AA053C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 27 | 4B57105E28D0AC8000AA053C /* AsyncMultiplexImage_Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AsyncMultiplexImage_Demo.entitlements; sourceTree = ""; }; 28 | 4B57106028D0AC8000AA053C /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 29 | 4B57106728D0AC8C00AA053C /* swiftui-AsyncMultiplexImage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swiftui-AsyncMultiplexImage"; path = ..; sourceTree = ""; }; 30 | 4B7E24F8296184E300E53388 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 31 | 4BB403CC2C19F9A80033B5E7 /* List.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = List.swift; sourceTree = ""; }; 32 | /* End PBXFileReference section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | 4B57105228D0AC7F00AA053C /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | 4B5AC66D2BB96AAB00641C3B /* MondrianLayout in Frameworks */, 40 | 4B5AC66A2BB96A8D00641C3B /* SwiftUIHosting in Frameworks */, 41 | 4B57106A28D0ACA300AA053C /* AsyncMultiplexImage in Frameworks */, 42 | 4B57106C28D0ACA800AA053C /* AsyncMultiplexImage-Nuke in Frameworks */, 43 | ); 44 | runOnlyForDeploymentPostprocessing = 0; 45 | }; 46 | /* End PBXFrameworksBuildPhase section */ 47 | 48 | /* Begin PBXGroup section */ 49 | 4B57104C28D0AC7F00AA053C = { 50 | isa = PBXGroup; 51 | children = ( 52 | 4B57106728D0AC8C00AA053C /* swiftui-AsyncMultiplexImage */, 53 | 4B57105728D0AC7F00AA053C /* AsyncMultiplexImage-Demo */, 54 | 4B57105628D0AC7F00AA053C /* Products */, 55 | 4B57106828D0ACA300AA053C /* Frameworks */, 56 | ); 57 | sourceTree = ""; 58 | }; 59 | 4B57105628D0AC7F00AA053C /* Products */ = { 60 | isa = PBXGroup; 61 | children = ( 62 | 4B57105528D0AC7F00AA053C /* AsyncMultiplexImage-Demo.app */, 63 | ); 64 | name = Products; 65 | sourceTree = ""; 66 | }; 67 | 4B57105728D0AC7F00AA053C /* AsyncMultiplexImage-Demo */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | 4B094D612D65B82000E4C4A9 /* ShrinkDemo.swift */, 71 | 4B57105A28D0AC7F00AA053C /* ContentView.swift */, 72 | 4B57105C28D0AC8000AA053C /* Assets.xcassets */, 73 | 4B57105E28D0AC8000AA053C /* AsyncMultiplexImage_Demo.entitlements */, 74 | 4B57105F28D0AC8000AA053C /* Preview Content */, 75 | 4B7E24F8296184E300E53388 /* AppDelegate.swift */, 76 | 4BB403CC2C19F9A80033B5E7 /* List.swift */, 77 | ); 78 | path = "AsyncMultiplexImage-Demo"; 79 | sourceTree = ""; 80 | }; 81 | 4B57105F28D0AC8000AA053C /* Preview Content */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | 4B57106028D0AC8000AA053C /* Preview Assets.xcassets */, 85 | ); 86 | path = "Preview Content"; 87 | sourceTree = ""; 88 | }; 89 | 4B57106828D0ACA300AA053C /* Frameworks */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | ); 93 | name = Frameworks; 94 | sourceTree = ""; 95 | }; 96 | /* End PBXGroup section */ 97 | 98 | /* Begin PBXNativeTarget section */ 99 | 4B57105428D0AC7F00AA053C /* AsyncMultiplexImage-Demo */ = { 100 | isa = PBXNativeTarget; 101 | buildConfigurationList = 4B57106428D0AC8000AA053C /* Build configuration list for PBXNativeTarget "AsyncMultiplexImage-Demo" */; 102 | buildPhases = ( 103 | 4B57105128D0AC7F00AA053C /* Sources */, 104 | 4B57105228D0AC7F00AA053C /* Frameworks */, 105 | 4B57105328D0AC7F00AA053C /* Resources */, 106 | ); 107 | buildRules = ( 108 | ); 109 | dependencies = ( 110 | ); 111 | name = "AsyncMultiplexImage-Demo"; 112 | packageProductDependencies = ( 113 | 4B57106928D0ACA300AA053C /* AsyncMultiplexImage */, 114 | 4B57106B28D0ACA800AA053C /* AsyncMultiplexImage-Nuke */, 115 | 4B5AC6692BB96A8D00641C3B /* SwiftUIHosting */, 116 | 4B5AC66C2BB96AAB00641C3B /* MondrianLayout */, 117 | ); 118 | productName = "AsyncMultiplexImage-Demo"; 119 | productReference = 4B57105528D0AC7F00AA053C /* AsyncMultiplexImage-Demo.app */; 120 | productType = "com.apple.product-type.application"; 121 | }; 122 | /* End PBXNativeTarget section */ 123 | 124 | /* Begin PBXProject section */ 125 | 4B57104D28D0AC7F00AA053C /* Project object */ = { 126 | isa = PBXProject; 127 | attributes = { 128 | BuildIndependentTargetsInParallel = 1; 129 | LastSwiftUpdateCheck = 1420; 130 | LastUpgradeCheck = 1400; 131 | TargetAttributes = { 132 | 4B57105428D0AC7F00AA053C = { 133 | CreatedOnToolsVersion = 14.0; 134 | }; 135 | }; 136 | }; 137 | buildConfigurationList = 4B57105028D0AC7F00AA053C /* Build configuration list for PBXProject "AsyncMultiplexImage-Demo" */; 138 | compatibilityVersion = "Xcode 14.0"; 139 | developmentRegion = en; 140 | hasScannedForEncodings = 0; 141 | knownRegions = ( 142 | en, 143 | Base, 144 | ); 145 | mainGroup = 4B57104C28D0AC7F00AA053C; 146 | packageReferences = ( 147 | 4B5AC6682BB96A8D00641C3B /* XCRemoteSwiftPackageReference "swiftui-hosting" */, 148 | 4B5AC66B2BB96AAB00641C3B /* XCRemoteSwiftPackageReference "MondrianLayout" */, 149 | ); 150 | productRefGroup = 4B57105628D0AC7F00AA053C /* Products */; 151 | projectDirPath = ""; 152 | projectRoot = ""; 153 | targets = ( 154 | 4B57105428D0AC7F00AA053C /* AsyncMultiplexImage-Demo */, 155 | ); 156 | }; 157 | /* End PBXProject section */ 158 | 159 | /* Begin PBXResourcesBuildPhase section */ 160 | 4B57105328D0AC7F00AA053C /* Resources */ = { 161 | isa = PBXResourcesBuildPhase; 162 | buildActionMask = 2147483647; 163 | files = ( 164 | 4B57106128D0AC8000AA053C /* Preview Assets.xcassets in Resources */, 165 | 4B57105D28D0AC8000AA053C /* Assets.xcassets in Resources */, 166 | ); 167 | runOnlyForDeploymentPostprocessing = 0; 168 | }; 169 | /* End PBXResourcesBuildPhase section */ 170 | 171 | /* Begin PBXSourcesBuildPhase section */ 172 | 4B57105128D0AC7F00AA053C /* Sources */ = { 173 | isa = PBXSourcesBuildPhase; 174 | buildActionMask = 2147483647; 175 | files = ( 176 | 4B7E24F9296184E300E53388 /* AppDelegate.swift in Sources */, 177 | 4B57105B28D0AC7F00AA053C /* ContentView.swift in Sources */, 178 | 4BB403CD2C19F9A80033B5E7 /* List.swift in Sources */, 179 | 4B094D622D65B82500E4C4A9 /* ShrinkDemo.swift in Sources */, 180 | ); 181 | runOnlyForDeploymentPostprocessing = 0; 182 | }; 183 | /* End PBXSourcesBuildPhase section */ 184 | 185 | /* Begin XCBuildConfiguration section */ 186 | 4B57106228D0AC8000AA053C /* Debug */ = { 187 | isa = XCBuildConfiguration; 188 | buildSettings = { 189 | ALWAYS_SEARCH_USER_PATHS = NO; 190 | CLANG_ANALYZER_NONNULL = YES; 191 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 192 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 193 | CLANG_ENABLE_MODULES = YES; 194 | CLANG_ENABLE_OBJC_ARC = YES; 195 | CLANG_ENABLE_OBJC_WEAK = YES; 196 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 197 | CLANG_WARN_BOOL_CONVERSION = YES; 198 | CLANG_WARN_COMMA = YES; 199 | CLANG_WARN_CONSTANT_CONVERSION = YES; 200 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 201 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 202 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 203 | CLANG_WARN_EMPTY_BODY = YES; 204 | CLANG_WARN_ENUM_CONVERSION = YES; 205 | CLANG_WARN_INFINITE_RECURSION = YES; 206 | CLANG_WARN_INT_CONVERSION = YES; 207 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 208 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 209 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 210 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 211 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 212 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 213 | CLANG_WARN_STRICT_PROTOTYPES = YES; 214 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 215 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 216 | CLANG_WARN_UNREACHABLE_CODE = YES; 217 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 218 | COPY_PHASE_STRIP = NO; 219 | DEBUG_INFORMATION_FORMAT = dwarf; 220 | ENABLE_STRICT_OBJC_MSGSEND = YES; 221 | ENABLE_TESTABILITY = YES; 222 | GCC_C_LANGUAGE_STANDARD = gnu11; 223 | GCC_DYNAMIC_NO_PIC = NO; 224 | GCC_NO_COMMON_BLOCKS = YES; 225 | GCC_OPTIMIZATION_LEVEL = 0; 226 | GCC_PREPROCESSOR_DEFINITIONS = ( 227 | "DEBUG=1", 228 | "$(inherited)", 229 | ); 230 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 231 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 232 | GCC_WARN_UNDECLARED_SELECTOR = YES; 233 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 234 | GCC_WARN_UNUSED_FUNCTION = YES; 235 | GCC_WARN_UNUSED_VARIABLE = YES; 236 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 237 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 238 | MTL_FAST_MATH = YES; 239 | ONLY_ACTIVE_ARCH = YES; 240 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 241 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 242 | }; 243 | name = Debug; 244 | }; 245 | 4B57106328D0AC8000AA053C /* Release */ = { 246 | isa = XCBuildConfiguration; 247 | buildSettings = { 248 | ALWAYS_SEARCH_USER_PATHS = NO; 249 | CLANG_ANALYZER_NONNULL = YES; 250 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 251 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 252 | CLANG_ENABLE_MODULES = YES; 253 | CLANG_ENABLE_OBJC_ARC = YES; 254 | CLANG_ENABLE_OBJC_WEAK = YES; 255 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 256 | CLANG_WARN_BOOL_CONVERSION = YES; 257 | CLANG_WARN_COMMA = YES; 258 | CLANG_WARN_CONSTANT_CONVERSION = YES; 259 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 260 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 261 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 262 | CLANG_WARN_EMPTY_BODY = YES; 263 | CLANG_WARN_ENUM_CONVERSION = YES; 264 | CLANG_WARN_INFINITE_RECURSION = YES; 265 | CLANG_WARN_INT_CONVERSION = YES; 266 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 267 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 268 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 269 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 270 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 271 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 272 | CLANG_WARN_STRICT_PROTOTYPES = YES; 273 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 274 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 275 | CLANG_WARN_UNREACHABLE_CODE = YES; 276 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 277 | COPY_PHASE_STRIP = NO; 278 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 279 | ENABLE_NS_ASSERTIONS = NO; 280 | ENABLE_STRICT_OBJC_MSGSEND = YES; 281 | GCC_C_LANGUAGE_STANDARD = gnu11; 282 | GCC_NO_COMMON_BLOCKS = YES; 283 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 284 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 285 | GCC_WARN_UNDECLARED_SELECTOR = YES; 286 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 287 | GCC_WARN_UNUSED_FUNCTION = YES; 288 | GCC_WARN_UNUSED_VARIABLE = YES; 289 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 290 | MTL_ENABLE_DEBUG_INFO = NO; 291 | MTL_FAST_MATH = YES; 292 | SWIFT_COMPILATION_MODE = wholemodule; 293 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 294 | }; 295 | name = Release; 296 | }; 297 | 4B57106528D0AC8000AA053C /* Debug */ = { 298 | isa = XCBuildConfiguration; 299 | buildSettings = { 300 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 301 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 302 | CODE_SIGN_ENTITLEMENTS = "AsyncMultiplexImage-Demo/AsyncMultiplexImage_Demo.entitlements"; 303 | CODE_SIGN_STYLE = Automatic; 304 | CURRENT_PROJECT_VERSION = 1; 305 | DEVELOPMENT_ASSET_PATHS = "\"AsyncMultiplexImage-Demo/Preview Content\""; 306 | DEVELOPMENT_TEAM = KU2QEJ9K3Z; 307 | ENABLE_PREVIEWS = YES; 308 | GENERATE_INFOPLIST_FILE = YES; 309 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 310 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 311 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 312 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 313 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 314 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 315 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 316 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 317 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 318 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 319 | MACOSX_DEPLOYMENT_TARGET = 12.3; 320 | MARKETING_VERSION = 1.0; 321 | PRODUCT_BUNDLE_IDENTIFIER = "app.muukii.AsyncMultiplexImage-Demo"; 322 | PRODUCT_NAME = "$(TARGET_NAME)"; 323 | SDKROOT = auto; 324 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 325 | SUPPORTS_MACCATALYST = NO; 326 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 327 | SWIFT_EMIT_LOC_STRINGS = YES; 328 | SWIFT_VERSION = 5.0; 329 | TARGETED_DEVICE_FAMILY = 1; 330 | }; 331 | name = Debug; 332 | }; 333 | 4B57106628D0AC8000AA053C /* Release */ = { 334 | isa = XCBuildConfiguration; 335 | buildSettings = { 336 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 337 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 338 | CODE_SIGN_ENTITLEMENTS = "AsyncMultiplexImage-Demo/AsyncMultiplexImage_Demo.entitlements"; 339 | CODE_SIGN_STYLE = Automatic; 340 | CURRENT_PROJECT_VERSION = 1; 341 | DEVELOPMENT_ASSET_PATHS = "\"AsyncMultiplexImage-Demo/Preview Content\""; 342 | DEVELOPMENT_TEAM = KU2QEJ9K3Z; 343 | ENABLE_PREVIEWS = YES; 344 | GENERATE_INFOPLIST_FILE = YES; 345 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 346 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 347 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 348 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 349 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 350 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 351 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 352 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 353 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 354 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 355 | MACOSX_DEPLOYMENT_TARGET = 12.3; 356 | MARKETING_VERSION = 1.0; 357 | PRODUCT_BUNDLE_IDENTIFIER = "app.muukii.AsyncMultiplexImage-Demo"; 358 | PRODUCT_NAME = "$(TARGET_NAME)"; 359 | SDKROOT = auto; 360 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 361 | SUPPORTS_MACCATALYST = NO; 362 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 363 | SWIFT_EMIT_LOC_STRINGS = YES; 364 | SWIFT_VERSION = 5.0; 365 | TARGETED_DEVICE_FAMILY = 1; 366 | }; 367 | name = Release; 368 | }; 369 | /* End XCBuildConfiguration section */ 370 | 371 | /* Begin XCConfigurationList section */ 372 | 4B57105028D0AC7F00AA053C /* Build configuration list for PBXProject "AsyncMultiplexImage-Demo" */ = { 373 | isa = XCConfigurationList; 374 | buildConfigurations = ( 375 | 4B57106228D0AC8000AA053C /* Debug */, 376 | 4B57106328D0AC8000AA053C /* Release */, 377 | ); 378 | defaultConfigurationIsVisible = 0; 379 | defaultConfigurationName = Release; 380 | }; 381 | 4B57106428D0AC8000AA053C /* Build configuration list for PBXNativeTarget "AsyncMultiplexImage-Demo" */ = { 382 | isa = XCConfigurationList; 383 | buildConfigurations = ( 384 | 4B57106528D0AC8000AA053C /* Debug */, 385 | 4B57106628D0AC8000AA053C /* Release */, 386 | ); 387 | defaultConfigurationIsVisible = 0; 388 | defaultConfigurationName = Release; 389 | }; 390 | /* End XCConfigurationList section */ 391 | 392 | /* Begin XCRemoteSwiftPackageReference section */ 393 | 4B5AC6682BB96A8D00641C3B /* XCRemoteSwiftPackageReference "swiftui-hosting" */ = { 394 | isa = XCRemoteSwiftPackageReference; 395 | repositoryURL = "https://github.com/FluidGroup/swiftui-hosting.git"; 396 | requirement = { 397 | kind = upToNextMajorVersion; 398 | minimumVersion = 1.2.0; 399 | }; 400 | }; 401 | 4B5AC66B2BB96AAB00641C3B /* XCRemoteSwiftPackageReference "MondrianLayout" */ = { 402 | isa = XCRemoteSwiftPackageReference; 403 | repositoryURL = "https://github.com/FluidGroup/MondrianLayout.git"; 404 | requirement = { 405 | kind = upToNextMajorVersion; 406 | minimumVersion = 0.10.0; 407 | }; 408 | }; 409 | /* End XCRemoteSwiftPackageReference section */ 410 | 411 | /* Begin XCSwiftPackageProductDependency section */ 412 | 4B57106928D0ACA300AA053C /* AsyncMultiplexImage */ = { 413 | isa = XCSwiftPackageProductDependency; 414 | productName = AsyncMultiplexImage; 415 | }; 416 | 4B57106B28D0ACA800AA053C /* AsyncMultiplexImage-Nuke */ = { 417 | isa = XCSwiftPackageProductDependency; 418 | productName = "AsyncMultiplexImage-Nuke"; 419 | }; 420 | 4B5AC6692BB96A8D00641C3B /* SwiftUIHosting */ = { 421 | isa = XCSwiftPackageProductDependency; 422 | package = 4B5AC6682BB96A8D00641C3B /* XCRemoteSwiftPackageReference "swiftui-hosting" */; 423 | productName = SwiftUIHosting; 424 | }; 425 | 4B5AC66C2BB96AAB00641C3B /* MondrianLayout */ = { 426 | isa = XCSwiftPackageProductDependency; 427 | package = 4B5AC66B2BB96AAB00641C3B /* XCRemoteSwiftPackageReference "MondrianLayout" */; 428 | productName = MondrianLayout; 429 | }; 430 | /* End XCSwiftPackageProductDependency section */ 431 | }; 432 | rootObject = 4B57104D28D0AC7F00AA053C /* Project object */; 433 | } 434 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "55f947f76b7d714c84aad5298d8f9a6cfe7fab1ab1c7983513cfe07b3f670be5", 3 | "pins" : [ 4 | { 5 | "identity" : "mondrianlayout", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/FluidGroup/MondrianLayout.git", 8 | "state" : { 9 | "revision" : "5f00b13984fe08316fc5b5be06e2f41c14a3befa", 10 | "version" : "0.10.0" 11 | } 12 | }, 13 | { 14 | "identity" : "nuke", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/kean/Nuke.git", 17 | "state" : { 18 | "revision" : "4625c73ea00a9fb4b4f3e28d95d0021a44af7e59", 19 | "version" : "12.5.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swiftui-hosting", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/FluidGroup/swiftui-hosting.git", 26 | "state" : { 27 | "revision" : "7e8eaca72eae910d6d3b6272c263c6c3a10b755c", 28 | "version" : "1.2.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swiftui-support", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/FluidGroup/swiftui-support.git", 35 | "state" : { 36 | "revision" : "10b463fc241552c4c6668700c37d4112ae926fe5", 37 | "version" : "0.12.0" 38 | } 39 | } 40 | ], 41 | "version" : 3 42 | } 43 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 57 | 58 | 59 | 60 | 66 | 68 | 74 | 75 | 76 | 77 | 79 | 80 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftUIBook 4 | // 5 | // Created by muukii on 2019/07/29. 6 | // Copyright © 2019 muukii. All rights reserved. 7 | // 8 | import UIKit 9 | import SwiftUI 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | // Override point for customization after application launch. 18 | 19 | let window = UIWindow() 20 | 21 | let controller = UIHostingController(rootView: ContentView()) 22 | 23 | window.rootViewController = controller 24 | self.window = window 25 | 26 | window.makeKeyAndVisible() 27 | 28 | return true 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/AsyncMultiplexImage_Demo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // AsyncMultiplexImage-Demo 4 | // 5 | // Created by Muukii on 2022/09/13. 6 | // 7 | 8 | import AsyncMultiplexImage 9 | import AsyncMultiplexImage_Nuke 10 | import MondrianLayout 11 | import Nuke 12 | import SwiftUI 13 | import SwiftUIHosting 14 | 15 | actor _SlowDownloader: AsyncMultiplexImageDownloader { 16 | 17 | let pipeline: ImagePipeline 18 | 19 | init(pipeline: ImagePipeline) { 20 | self.pipeline = pipeline 21 | } 22 | 23 | func download(candidate: AsyncMultiplexImageCandidate, displaySize: CGSize) async throws 24 | -> UIImage 25 | { 26 | 27 | switch candidate.index { 28 | case 0: 29 | try? await Task.sleep(nanoseconds: 2_000_000_000) 30 | case 1: 31 | try? await Task.sleep(nanoseconds: 1_500_000_000) 32 | case 2: 33 | try? await Task.sleep(nanoseconds: 1_000_000_000) 34 | case 3: 35 | try? await Task.sleep(nanoseconds: 0_500_000_000) 36 | default: 37 | break 38 | } 39 | 40 | let response = try await pipeline.image(for: .init(urlRequest: candidate.urlRequest)) 41 | return response 42 | } 43 | 44 | } 45 | 46 | struct ContentView: View { 47 | 48 | var body: some View { 49 | NavigationView { 50 | Form { 51 | Section { 52 | NavigationLink("SwiftUI") { 53 | SwitchingDemo() 54 | .navigationTitle("SwiftUI") 55 | } 56 | NavigationLink("UIKit") { 57 | UIKitContentViewRepresentable() 58 | } 59 | 60 | NavigationLink("Stress 1", destination: { StressGrid() }) 61 | 62 | NavigationLink("Stress 2", destination: { StressGrid() }) 63 | 64 | NavigationLink("Shrink", destination: { 65 | BookShrink() 66 | }) 67 | } 68 | .navigationTitle("Multiplex Image") 69 | } 70 | } 71 | } 72 | } 73 | 74 | private struct SwitchingDemo: View { 75 | 76 | @State private var basePhotoURLString: String = 77 | "https://images.unsplash.com/photo-1492446845049-9c50cc313f00" 78 | 79 | var body: some View { 80 | VStack { 81 | AsyncMultiplexImage( 82 | multiplexImage: .init( 83 | identifier: basePhotoURLString, 84 | urls: buildURLs(basePhotoURLString) 85 | ), 86 | downloader: _SlowDownloader(pipeline: .shared), 87 | content: AsyncMultiplexImageBasicContent() 88 | ) 89 | 90 | HStack { 91 | Button("1") { 92 | basePhotoURLString = 93 | "https://images.unsplash.com/photo-1660668377331-da480e5339a0" 94 | } 95 | Button("2") { 96 | basePhotoURLString = 97 | "https://images.unsplash.com/photo-1658214764191-b002b517e9e5" 98 | } 99 | Button("3") { 100 | basePhotoURLString = 101 | "https://images.unsplash.com/photo-1587126396803-be14d33e49cf" 102 | } 103 | } 104 | } 105 | .padding() 106 | } 107 | 108 | } 109 | 110 | struct UIKitContentViewRepresentable: UIViewRepresentable { 111 | 112 | func makeUIView(context: Context) -> UIKitContentView { 113 | .init() 114 | } 115 | 116 | func updateUIView(_ uiView: UIKitContentView, context: Context) { 117 | 118 | } 119 | 120 | } 121 | 122 | final class UIKitContentView: UIView { 123 | 124 | private let imageView: AsyncMultiplexImageView = .init( 125 | downloader: _SlowDownloader(pipeline: .shared), 126 | clearsContentBeforeDownload: true 127 | ) 128 | 129 | init() { 130 | 131 | super.init(frame: .null) 132 | 133 | imageView.backgroundColor = .init(white: 0.5, alpha: 0.2) 134 | 135 | let buttonsView = SwiftUIHostingView { [imageView] in 136 | HStack { 137 | Button("1") { 138 | 139 | let basePhotoURLString = "https://images.unsplash.com/photo-1660668377331-da480e5339a0" 140 | 141 | imageView.setMultiplexImage( 142 | .init( 143 | identifier: basePhotoURLString, 144 | urls: buildURLs(basePhotoURLString) 145 | ) 146 | ) 147 | 148 | } 149 | Button("2") { 150 | let basePhotoURLString = "https://images.unsplash.com/photo-1658214764191-b002b517e9e5" 151 | 152 | imageView.setMultiplexImage( 153 | .init( 154 | identifier: basePhotoURLString, 155 | urls: buildURLs(basePhotoURLString) 156 | ) 157 | ) 158 | 159 | } 160 | Button("3") { 161 | let basePhotoURLString = "https://images.unsplash.com/photo-1587126396803-be14d33e49cf" 162 | 163 | imageView.setMultiplexImage( 164 | .init( 165 | identifier: basePhotoURLString, 166 | urls: buildURLs(basePhotoURLString) 167 | ) 168 | ) 169 | } 170 | } 171 | } 172 | 173 | Mondrian.buildSubviews(on: self) { 174 | VStackBlock { 175 | imageView 176 | .viewBlock 177 | .size(.init(width: 300, height: 300)) 178 | buttonsView 179 | } 180 | } 181 | } 182 | 183 | required init?(coder: NSCoder) { 184 | fatalError("init(coder:) has not been implemented") 185 | } 186 | 187 | } 188 | 189 | @available(iOS 17, *)#Preview("UIKit"){ 190 | let view = AsyncMultiplexImageView( 191 | downloader: _SlowDownloader(pipeline: .shared), 192 | clearsContentBeforeDownload: true 193 | ) 194 | view.setMultiplexImage( 195 | .init( 196 | identifier: "https://images.unsplash.com/photo-1660668377331-da480e5339a0", 197 | urls: buildURLs("https://images.unsplash.com/photo-1660668377331-da480e5339a0") 198 | ) 199 | ) 200 | view.frame = .init(origin: .zero, size: .init(width: 300, height: 300)) 201 | return view 202 | } 203 | 204 | struct ContentView_Previews: PreviewProvider { 205 | static var previews: some View { 206 | ContentView() 207 | } 208 | } 209 | 210 | func buildURLs(_ baseURLString: String) -> [URL] { 211 | 212 | var components = URLComponents(string: baseURLString)! 213 | 214 | return [ 215 | "", 216 | "w=100", 217 | "w=50", 218 | "w=10", 219 | ].map { 220 | 221 | components.query = $0 222 | 223 | return components.url! 224 | 225 | } 226 | 227 | } 228 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/List.swift: -------------------------------------------------------------------------------- 1 | import AsyncMultiplexImage 2 | import AsyncMultiplexImage_Nuke 3 | import SwiftUI 4 | 5 | struct StressGrid: View { 6 | 7 | @State var items: [Entity] = Entity.batch() 8 | 9 | var body: some View { 10 | GeometryReader { proxy in 11 | ScrollView { 12 | LazyVGrid( 13 | columns: [ 14 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), 15 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), 16 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), 17 | .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), 18 | ], spacing: 2 19 | ) { 20 | ForEach(items) { entity in 21 | Cell(entity: entity) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | } 29 | 30 | #Preview { 31 | StressGrid() 32 | } 33 | 34 | #Preview { 35 | StressGrid() 36 | } 37 | 38 | let imageURLString = 39 | "https://images.unsplash.com/photo-1567095761054-7a02e69e5c43?q=80&w=800&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" 40 | 41 | protocol CellType: View { 42 | init(entity: Entity) 43 | } 44 | 45 | struct Cell_1: View, CellType { 46 | 47 | let entity: Entity 48 | 49 | var body: some View { 50 | AsyncMultiplexImageNuke(imageRepresentation: .remote(entity.image)) 51 | .frame(height: 100) 52 | } 53 | } 54 | 55 | 56 | struct Cell_2: View, CellType { 57 | 58 | let entity: Entity 59 | 60 | var body: some View { 61 | VStack { 62 | AsyncMultiplexImageNuke(imageRepresentation: .remote(entity.image)) 63 | .frame(height: 100) 64 | .clipShape( 65 | RoundedRectangle( 66 | cornerRadius: 20, 67 | style: .continuous 68 | ) 69 | ) 70 | .frame(maxWidth: .infinity) 71 | .aspectRatio(1, contentMode: .fit) 72 | } 73 | .padding() 74 | } 75 | } 76 | 77 | struct Cell_3: View, CellType { 78 | 79 | final class Object: ObservableObject { 80 | 81 | @Published var value: Int = 0 82 | 83 | init() { 84 | print("Object.init") 85 | } 86 | 87 | deinit { 88 | // print("Object.deinit") 89 | 90 | } 91 | } 92 | 93 | let entity: Entity 94 | 95 | @State private var value: Int = 0 96 | @StateObject private var object = Object() 97 | 98 | var body: some View { 99 | let _ = Self._printChanges() 100 | VStack { 101 | Button("Up \(value)") { 102 | value += 1 103 | } 104 | Button("Up \(object.value)") { 105 | object.value += 1 106 | } 107 | } 108 | .padding() 109 | } 110 | } 111 | 112 | struct Entity: Identifiable { 113 | 114 | let id: UUID 115 | let name: String 116 | let image: MultiplexImage 117 | 118 | static func make() -> Self { 119 | return .init( 120 | id: .init(), 121 | name: "Hello", 122 | image: .init( 123 | constant: URL(string: imageURLString + "&tag=\(UUID().uuidString)")! 124 | ) 125 | ) 126 | } 127 | 128 | static func batch() -> [Self] { 129 | (0..<100000).map { _ in 130 | .make() 131 | } 132 | } 133 | 134 | static nonisolated func delayBatch() async -> [Self] { 135 | try? await Task.sleep(nanoseconds: 1_000_000_000) 136 | return (0..<100).map { _ in 137 | .make() 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Development/AsyncMultiplexImage-Demo/ShrinkDemo.swift: -------------------------------------------------------------------------------- 1 | import AsyncMultiplexImage 2 | // 3 | // ShrinkDemo.swift 4 | // AsyncMultiplexImage-Demo 5 | // 6 | // Created by Muukii on 2025/02/19. 7 | // 8 | import SwiftUI 9 | 10 | struct BookShrink: View, PreviewProvider { 11 | var body: some View { 12 | ContentView() 13 | } 14 | 15 | static var previews: some View { 16 | Self() 17 | .previewDisplayName(nil) 18 | } 19 | 20 | private struct ContentView: View { 21 | 22 | @State private var isPressing = false 23 | 24 | var body: some View { 25 | AsyncMultiplexImage( 26 | multiplexImage: .init( 27 | identifier: "https://images.unsplash.com/photo-1660668377331-da480e5339a0", 28 | urls: buildURLs("https://images.unsplash.com/photo-1660668377331-da480e5339a0") 29 | ), 30 | downloader: _SlowDownloader(pipeline: .shared), 31 | content: AsyncMultiplexImageBasicContent() 32 | ) 33 | .scaleEffect(isPressing ? 0.5 : 1) 34 | .padding(20) 35 | ._onButtonGesture( 36 | pressing: { isPressing in 37 | withAnimation(.spring) { 38 | self.isPressing = isPressing 39 | } 40 | }) { 41 | 42 | } 43 | 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Hiroshi Kimura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "9d5b7a055dcb86f199dc58cfa00698ea76355065e6b24365c8c3b8a8fb093663", 3 | "pins" : [ 4 | { 5 | "identity" : "nuke", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/kean/Nuke.git", 8 | "state" : { 9 | "revision" : "6241e100294a2aa70d1811641585ab7da780bd0f", 10 | "version" : "12.0.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swiftui-support", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/FluidGroup/swiftui-support.git", 17 | "state" : { 18 | "revision" : "10b463fc241552c4c6668700c37d4112ae926fe5", 19 | "version" : "0.12.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AsyncMultiplexImage", 8 | platforms: [ 9 | .iOS(.v16), 10 | ], 11 | products: [ 12 | .library( 13 | name: "AsyncMultiplexImage", 14 | targets: ["AsyncMultiplexImage"] 15 | ), 16 | .library( 17 | name: "AsyncMultiplexImage-Nuke", 18 | targets: ["AsyncMultiplexImage-Nuke"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/kean/Nuke.git", from: "12.0.0"), 23 | .package(url: "https://github.com/FluidGroup/swiftui-support.git", from: "0.12.0") 24 | ], 25 | targets: [ 26 | .target( 27 | name: "AsyncMultiplexImage", 28 | dependencies: [.product(name: "SwiftUISupportBackport", package: "swiftui-support")] 29 | ), 30 | .target( 31 | name: "AsyncMultiplexImage-Nuke", 32 | dependencies: ["Nuke", "AsyncMultiplexImage"] 33 | ), 34 | .testTarget( 35 | name: "AsyncMultiplexImageTests", 36 | dependencies: ["AsyncMultiplexImage"] 37 | ), 38 | ], 39 | swiftLanguageModes: [.v6] 40 | ) 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AsyncMultiplexImage for SwiftUI 2 | 3 | 4 | 5 | This library provides an asynchronous image loading solution for SwiftUI applications. It supports loading multiple image resolutions and automatically handles displaying the most appropriate image based on the available space. The library uses Swift's concurrency model, including actors and tasks, to manage image downloading efficiently. 6 | 7 | ## Features 8 | 9 | - Asynchronous image downloading 10 | - Supports multiple image resolutions 11 | - Efficient image loading using Swift's concurrency model 12 | - Logging utilities for debugging and error handling 13 | 14 | ## Installation 15 | 16 | ### Swift Package Manager 17 | 18 | Add the following dependency to your `Package.swift` file: 19 | 20 | ```swift 21 | dependencies: [ 22 | .package(url: "https://github.com/YourGitHubUsername/AsyncMultiplexImage.git", from: "1.0.0") 23 | ] 24 | ``` 25 | 26 | ## Starter 27 | 28 | ``` 29 | import AsyncMultiplexImage 30 | 31 | AsyncMultiplexImageNuke(image: .init(constant: URL(...))) 32 | ``` 33 | 34 | ## Usage 35 | 36 | 1. Import the library: 37 | 38 | ```swift 39 | import AsyncMultiplexImage 40 | ``` 41 | 42 | 2. Define a `MultiplexImage` with a unique identifier and a closure that returns a list of URLs for different image resolutions: 43 | 44 | ```swift 45 | let multiplexImage = MultiplexImage(identifier: "imageID", urlsProvider: { _ in 46 | [URL(string: "https://example.com/image_small.png")!, 47 | URL(string: "https://example.com/image_medium.png")!, 48 | URL(string: "https://example.com/image_large.png")!] 49 | }) 50 | ``` 51 | 52 | 3. Create an `AsyncMultiplexImage` view using the `MultiplexImage` and a custom downloader conforming to `AsyncMultiplexImageDownloader`: 53 | 54 | ```swift 55 | struct MyImageView: View { 56 | let multiplexImage: MultiplexImage 57 | let downloader: MyImageDownloader 58 | 59 | var body: some View { 60 | AsyncMultiplexImage(multiplexImage: multiplexImage, downloader: downloader) { phase in 61 | switch phase { 62 | case .empty: 63 | ProgressView() 64 | case .progress(let image): 65 | image.resizable() 66 | case .success(let image): 67 | image.resizable() 68 | case .failure(let error): 69 | Text("Error: \(error.localizedDescription)") 70 | } 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | 4. Implement a custom image downloader conforming to `AsyncMultiplexImageDownloader`: 77 | 78 | ```swift 79 | class MyImageDownloader: AsyncMultiplexImageDownloader { 80 | func download(candidate: AsyncMultiplexImageCandidate) async throws -> Image { 81 | // Download the image and return a SwiftUI.Image instance 82 | } 83 | } 84 | ``` 85 | 86 | ## License 87 | 88 | This library is available under the MIT License. See the [LICENSE](LICENSE) file for more information. 89 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNuke.swift: -------------------------------------------------------------------------------- 1 | import AsyncMultiplexImage 2 | import SwiftUI 3 | 4 | public struct AsyncMultiplexImageNuke: View { 5 | 6 | public let imageRepresentation: ImageRepresentation 7 | 8 | public init(imageRepresentation: ImageRepresentation) { 9 | self.imageRepresentation = imageRepresentation 10 | } 11 | 12 | public var body: some View { 13 | AsyncMultiplexImage( 14 | imageRepresentation: imageRepresentation, 15 | downloader: AsyncMultiplexImageNukeDownloader.shared, 16 | content: AsyncMultiplexImageBasicContent() 17 | ) 18 | } 19 | 20 | } 21 | 22 | #Preview { 23 | AsyncMultiplexImageNuke( 24 | imageRepresentation: .remote( 25 | .init( 26 | constant: URL( 27 | string: 28 | "https://images.unsplash.com/photo-1492446845049-9c50cc313f00?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8" 29 | )! 30 | ) 31 | ) 32 | ) 33 | } 34 | 35 | #Preview("Rotating") { 36 | HStack { 37 | 38 | Rectangle() 39 | .frame(width: 100, height: 100) 40 | .rotationEffect(.degrees(10)) 41 | 42 | AsyncMultiplexImageNuke( 43 | imageRepresentation: .remote( 44 | .init( 45 | constant: URL( 46 | string: 47 | "https://images.unsplash.com/photo-1492446845049-9c50cc313f00?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8" 48 | )! 49 | ) 50 | ) 51 | ) 52 | .frame(width: 100, height: 100) 53 | .rotationEffect(.degrees(10)) 54 | .clipped(antialiased: true) 55 | 56 | AsyncMultiplexImageNuke( 57 | imageRepresentation: .remote( 58 | .init( 59 | constant: URL( 60 | string: 61 | "https://images.unsplash.com/photo-1492446845049-9c50cc313f00?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8" 62 | )! 63 | ) 64 | ) 65 | ) 66 | .frame(width: 100, height: 100) 67 | .rotationEffect(.degrees(20)) 68 | 69 | AsyncMultiplexImageNuke( 70 | imageRepresentation: .remote( 71 | .init( 72 | constant: URL( 73 | string: 74 | "https://images.unsplash.com/photo-1492446845049-9c50cc313f00?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8" 75 | )! 76 | ) 77 | ) 78 | ) 79 | .frame(width: 100, height: 100) 80 | .rotationEffect(.degrees(30)) 81 | } 82 | } 83 | 84 | #Preview { 85 | AsyncMultiplexImageNuke( 86 | imageRepresentation: .loaded(Image(systemName: "photo")) 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNukeDownloader.swift: -------------------------------------------------------------------------------- 1 | import AsyncMultiplexImage 2 | import Foundation 3 | import Nuke 4 | import SwiftUI 5 | 6 | public actor AsyncMultiplexImageNukeDownloader: AsyncMultiplexImageDownloader { 7 | 8 | public static let `shared` = AsyncMultiplexImageNukeDownloader(pipeline: .shared, debugDelay: 0) 9 | 10 | public let pipeline: ImagePipeline 11 | public let debugDelay: TimeInterval 12 | 13 | public init( 14 | pipeline: ImagePipeline, 15 | debugDelay: TimeInterval 16 | ) { 17 | self.pipeline = pipeline 18 | self.debugDelay = debugDelay 19 | } 20 | 21 | public func download( 22 | candidate: AsyncMultiplexImageCandidate, 23 | displaySize: CGSize 24 | ) async throws -> DownloadResult { 25 | 26 | #if DEBUG 27 | 28 | try? await Task.sleep(nanoseconds: UInt64(debugDelay * 1_000_000_000)) 29 | 30 | #endif 31 | 32 | let task = pipeline.imageTask(with: .init( 33 | urlRequest: candidate.urlRequest, 34 | processors: [ 35 | ImageProcessors.Resize( 36 | size: displaySize, 37 | unit: .points, 38 | contentMode: .aspectFill, 39 | crop: true, 40 | upscale: false 41 | ) 42 | ] 43 | ) 44 | ) 45 | 46 | let begin = CACurrentMediaTime() 47 | 48 | let result = try await task.response 49 | 50 | let end = CACurrentMediaTime() 51 | 52 | let took = end - begin 53 | 54 | var isFromCache: Bool { 55 | switch result.cacheType { 56 | case .memory, .disk: 57 | return true 58 | default: 59 | return false 60 | } 61 | } 62 | 63 | return .init( 64 | image: result.image, 65 | isFromCache: false, 66 | time: took 67 | ) 68 | 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import SwiftUISupportBackport 4 | import os.log 5 | 6 | enum Log { 7 | 8 | static func debug( 9 | file: StaticString = #file, 10 | line: UInt = #line, 11 | _ log: OSLog, 12 | _ object: @autoclosure () -> Any 13 | ) { 14 | os_log( 15 | .default, 16 | log: log, 17 | "%{public}@\n%{public}@:%{public}@", 18 | "\(object())", 19 | "\(file)", 20 | "\(line.description)" 21 | ) 22 | } 23 | 24 | static func error( 25 | file: StaticString = #file, 26 | line: UInt = #line, 27 | _ log: OSLog, 28 | _ object: @autoclosure () -> Any 29 | ) { 30 | os_log( 31 | .error, 32 | log: log, 33 | "%{public}@\n%{public}@:%{public}@", 34 | "\(object())", 35 | "\(file)", 36 | "\(line.description)" 37 | ) 38 | } 39 | 40 | } 41 | 42 | extension OSLog { 43 | 44 | @inline(__always) 45 | private static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog { 46 | #if DEBUG 47 | if ProcessInfo.processInfo.environment["ASYNC_MULTIPLEX_IMAGE_LOG_ENABLED"] == "1" { 48 | return factory() 49 | } else { 50 | return .disabled 51 | } 52 | #else 53 | return .disabled 54 | #endif 55 | } 56 | 57 | static let generic: OSLog = makeOSLogInDebug(isEnabled: false) { 58 | OSLog.init(subsystem: "app.muukii", category: "default") 59 | } 60 | static let view: OSLog = makeOSLogInDebug { 61 | OSLog.init(subsystem: "app.muukii", category: "SwiftUIVersion") 62 | } 63 | 64 | static let uiKit: OSLog = makeOSLogInDebug { 65 | OSLog.init(subsystem: "app.muukii", category: "UIKitVersion") 66 | } 67 | 68 | } 69 | 70 | public struct DownloadResult: Sendable { 71 | 72 | public struct Metrics: Sendable, Equatable { 73 | 74 | public let isFromCache: Bool 75 | public let time: TimeInterval 76 | 77 | } 78 | 79 | public let image: UIImage 80 | public let metrics: Metrics 81 | 82 | public init( 83 | image: UIImage, 84 | isFromCache: Bool, 85 | time: TimeInterval 86 | ) { 87 | self.image = image 88 | self.metrics = .init( 89 | isFromCache: isFromCache, 90 | time: time 91 | ) 92 | 93 | } 94 | } 95 | 96 | public protocol AsyncMultiplexImageDownloader: Actor { 97 | 98 | func download( 99 | candidate: AsyncMultiplexImageCandidate, 100 | displaySize: CGSize 101 | ) async throws 102 | -> DownloadResult 103 | 104 | } 105 | 106 | public enum Source: Equatable, Sendable { 107 | case local 108 | case remote(DownloadResult.Metrics) 109 | } 110 | 111 | public enum AsyncMultiplexImagePhase { 112 | case empty 113 | case progress(Image, Source) 114 | case success(Image, Source) 115 | case failure(Error) 116 | } 117 | 118 | public struct AsyncMultiplexImageCandidate: Hashable, Sendable { 119 | 120 | public let index: Int 121 | public let urlRequest: URLRequest 122 | 123 | } 124 | 125 | public enum ImageRepresentation: Equatable { 126 | case remote(MultiplexImage) 127 | case loaded(Image) 128 | } 129 | 130 | public struct AsyncMultiplexImage< 131 | Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader 132 | >: View { 133 | 134 | private let imageRepresentation: ImageRepresentation 135 | private let downloader: Downloader 136 | private let content: Content 137 | 138 | private let clearsContentBeforeDownload: Bool 139 | 140 | // convenience init 141 | public init( 142 | multiplexImage: MultiplexImage, 143 | downloader: Downloader, 144 | clearsContentBeforeDownload: Bool = true, 145 | content: Content 146 | ) { 147 | self.init( 148 | imageRepresentation: .remote(multiplexImage), 149 | downloader: downloader, 150 | clearsContentBeforeDownload: clearsContentBeforeDownload, 151 | content: content 152 | ) 153 | } 154 | 155 | public init( 156 | imageRepresentation: ImageRepresentation, 157 | downloader: Downloader, 158 | clearsContentBeforeDownload: Bool = true, 159 | content: Content 160 | ) { 161 | 162 | self.clearsContentBeforeDownload = clearsContentBeforeDownload 163 | self.imageRepresentation = imageRepresentation 164 | self.downloader = downloader 165 | self.content = content 166 | 167 | } 168 | 169 | public var body: some View { 170 | _AsyncMultiplexImage( 171 | clearsContentBeforeDownload: clearsContentBeforeDownload, 172 | imageRepresentation: imageRepresentation, 173 | downloader: downloader, 174 | content: content 175 | ) 176 | } 177 | 178 | } 179 | 180 | private struct _AsyncMultiplexImage< 181 | Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader 182 | >: View { 183 | 184 | private struct UpdateTrigger: Equatable { 185 | let size: CGSize 186 | let image: ImageRepresentation 187 | } 188 | 189 | @State private var item: ResultContainer.ItemSwiftUI? 190 | 191 | @State private var displaySize: CGSize = .zero 192 | @Environment(\.displayScale) var displayScale 193 | 194 | private let imageRepresentation: ImageRepresentation 195 | private let downloader: Downloader 196 | private let content: Content 197 | private let clearsContentBeforeDownload: Bool 198 | 199 | public init( 200 | clearsContentBeforeDownload: Bool, 201 | imageRepresentation: ImageRepresentation, 202 | downloader: Downloader, 203 | content: Content 204 | ) { 205 | 206 | self.clearsContentBeforeDownload = clearsContentBeforeDownload 207 | self.imageRepresentation = imageRepresentation 208 | self.downloader = downloader 209 | self.content = content 210 | } 211 | 212 | private static func phase(from: ResultContainer.ItemSwiftUI?) -> AsyncMultiplexImagePhase { 213 | 214 | guard let from else { 215 | return .empty 216 | } 217 | 218 | switch from.phase { 219 | case .progress(let image, let source): 220 | return .progress(image, source) 221 | case .final(let image, let source): 222 | return .success(image, source) 223 | } 224 | } 225 | 226 | public var body: some View { 227 | 228 | Color.clear 229 | .overlay( 230 | content.body( 231 | phase: Self.phase(from: item) 232 | ) 233 | .frame(width: displaySize.width, height: displaySize.height) 234 | ) 235 | .onGeometryChange( 236 | for: CGSize.self, 237 | of: \.size, 238 | action: { newValue in 239 | displaySize = newValue 240 | } 241 | ) 242 | .task( 243 | id: UpdateTrigger( 244 | size: displaySize, 245 | image: imageRepresentation 246 | ), 247 | { 248 | 249 | if let item, 250 | case .final = item.phase, 251 | item.representation == imageRepresentation { 252 | // already final item loaded 253 | return 254 | } 255 | 256 | await withTaskCancellationHandler { 257 | 258 | let newSize = displaySize 259 | 260 | guard newSize.height > 0 && newSize.width > 0 else { 261 | return 262 | } 263 | 264 | if clearsContentBeforeDownload { 265 | var transaction = Transaction() 266 | transaction.disablesAnimations = true 267 | withTransaction(transaction) { 268 | self.item = nil 269 | } 270 | } 271 | 272 | switch imageRepresentation { 273 | case .remote(let multiplexImage): 274 | 275 | let displayScale = self.displayScale 276 | let candidates = await pushBackground { 277 | 278 | // making new candidates 279 | let context = MultiplexImage.Context( 280 | targetSize: newSize, 281 | displayScale: displayScale 282 | ) 283 | 284 | let urls = multiplexImage.makeURLs(context: context) 285 | 286 | let candidates = urls.enumerated().map { i, e in 287 | AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) 288 | } 289 | 290 | return candidates 291 | } 292 | 293 | guard Task.isCancelled == false else { 294 | return 295 | } 296 | 297 | let stream = await DownloadManager.shared.start( 298 | source: multiplexImage, 299 | candidates: candidates, 300 | downloader: downloader, 301 | displaySize: newSize 302 | ) 303 | 304 | guard Task.isCancelled == false else { 305 | return 306 | } 307 | 308 | do { 309 | for try await item in stream { 310 | 311 | guard Task.isCancelled == false else { 312 | return 313 | } 314 | 315 | await MainActor.run { 316 | self.item = .init( 317 | representation: imageRepresentation, 318 | phase: item.swiftUI 319 | ) 320 | } 321 | } 322 | } catch { 323 | // FIXME: Error handling 324 | } 325 | 326 | case .loaded(let image): 327 | 328 | self.item = .init( 329 | representation: imageRepresentation, 330 | phase: .final(image, .local) 331 | ) 332 | 333 | } 334 | } onCancel: { 335 | // handle cancel 336 | } 337 | 338 | }) 339 | .clipped(antialiased: true) 340 | // .onDisappear { 341 | // self.task?.cancel() 342 | // self.task = nil 343 | // } 344 | 345 | } 346 | 347 | } 348 | 349 | private func pushBackground(task: @Sendable () -> sending Result) async -> sending Result { 350 | task() 351 | } 352 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage/AsyncMultiplexImageContent.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public protocol AsyncMultiplexImageContent { 4 | 5 | associatedtype Content: View 6 | 7 | @ViewBuilder 8 | func body(phase: AsyncMultiplexImagePhase) -> Content 9 | } 10 | 11 | public struct AsyncMultiplexImageBasicContent: AsyncMultiplexImageContent { 12 | 13 | public init() {} 14 | 15 | public func body(phase: AsyncMultiplexImagePhase) -> some View { 16 | switch phase { 17 | case .empty: 18 | Rectangle().fill(.clear) 19 | case .progress(let image, _): 20 | image 21 | .resizable() 22 | .scaledToFill() 23 | .transition(.opacity.animation(.bouncy)) 24 | case .success(let image, _): 25 | image 26 | .resizable() 27 | .scaledToFill() 28 | .transition(.opacity.animation(.bouncy)) 29 | case .failure: 30 | Rectangle().fill(.clear) 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(UIKit) 3 | import UIKit 4 | 5 | open class AsyncMultiplexImageView: UIView { 6 | 7 | public protocol OffloadStrategy { 8 | func offloads(using state: borrowing State) -> Bool 9 | } 10 | 11 | public struct OffloadInvisibleStrategy: OffloadStrategy { 12 | 13 | public init() { 14 | 15 | } 16 | 17 | public func offloads(using state: borrowing State) -> Bool { 18 | state.isInDisplay == false 19 | } 20 | } 21 | 22 | public struct State: ~Copyable { 23 | 24 | /// Whether the app is in background state 25 | public var isInBackground: Bool = false 26 | 27 | /// Whether the view is in view hierarchy 28 | public var isInDisplay: Bool = false 29 | } 30 | 31 | // MARK: - Properties 32 | 33 | public let downloader: any AsyncMultiplexImageDownloader 34 | public let offloadStrategy: (any OffloadStrategy)? 35 | 36 | private var task: Task? 37 | 38 | private var currentUsingNetworkImage: MultiplexImage? 39 | private var currentUsingImage: UIImage? 40 | 41 | private var currentUsingContentSize: CGSize? 42 | private let clearsContentBeforeDownload: Bool 43 | 44 | private let imageView: UIImageView = .init() 45 | 46 | private var state: State = .init() { 47 | didSet { 48 | onUpdateState(state: state) 49 | } 50 | } 51 | 52 | // MARK: - Initializers 53 | 54 | public init( 55 | downloader: any AsyncMultiplexImageDownloader, 56 | offloadStrategy: (any OffloadStrategy)? = nil, 57 | clearsContentBeforeDownload: Bool = true 58 | ) { 59 | 60 | self.downloader = downloader 61 | self.offloadStrategy = offloadStrategy 62 | self.clearsContentBeforeDownload = clearsContentBeforeDownload 63 | 64 | super.init(frame: .null) 65 | 66 | imageView.clipsToBounds = true 67 | imageView.contentMode = .scaleAspectFill 68 | 69 | NotificationCenter.default.addObserver( 70 | self, 71 | selector: #selector(didEnterBackground), 72 | name: UIApplication.didEnterBackgroundNotification, 73 | object: nil 74 | ) 75 | 76 | NotificationCenter.default.addObserver( 77 | self, 78 | selector: #selector(willEnterForeground), 79 | name: UIApplication.willEnterForegroundNotification, 80 | object: nil 81 | ) 82 | 83 | addSubview(imageView) 84 | imageView.translatesAutoresizingMaskIntoConstraints = false 85 | NSLayoutConstraint.activate([ 86 | imageView.topAnchor.constraint(equalTo: topAnchor), 87 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor), 88 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor), 89 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor) 90 | ]) 91 | 92 | } 93 | 94 | @available(*, unavailable) 95 | public required init?(coder: NSCoder) { 96 | fatalError("init(coder:) has not been implemented") 97 | } 98 | 99 | deinit { 100 | Log.debug(.uiKit, "deinit \(self)") 101 | } 102 | 103 | // MARK: - Functions 104 | 105 | private func onUpdateState(state: borrowing State) { 106 | 107 | if state.isInBackground || state.isInDisplay == false { 108 | 109 | let offloads = offloadStrategy?.offloads(using: state) 110 | 111 | if let offloads, offloads { 112 | self.task?.cancel() 113 | self.task = nil 114 | unloadNetworkImage() 115 | } 116 | 117 | } else { 118 | if let _ = currentUsingNetworkImage { 119 | startDownload() 120 | } 121 | } 122 | 123 | } 124 | 125 | open override func layoutSubviews() { 126 | super.layoutSubviews() 127 | 128 | if let _ = currentUsingNetworkImage, bounds.size != currentUsingContentSize { 129 | currentUsingContentSize = bounds.size 130 | startDownload() 131 | } 132 | } 133 | 134 | open override func willMove(toWindow newWindow: UIWindow?) { 135 | super.willMove(toWindow: newWindow) 136 | state.isInDisplay = newWindow != nil 137 | } 138 | 139 | @objc 140 | private func didEnterBackground() { 141 | state.isInBackground = true 142 | } 143 | 144 | @objc 145 | private func willEnterForeground() { 146 | state.isInBackground = false 147 | } 148 | 149 | public func setMultiplexImage(_ image: MultiplexImage) { 150 | currentUsingNetworkImage = image 151 | startDownload() 152 | } 153 | 154 | public func setImage(_ image: UIImage) { 155 | 156 | if clearsContentBeforeDownload { 157 | imageView.image = nil 158 | } 159 | 160 | currentUsingNetworkImage = nil 161 | currentUsingImage = image 162 | imageView.image = image 163 | 164 | self.task?.cancel() 165 | self.task = nil 166 | 167 | } 168 | 169 | public func clearImage() { 170 | currentUsingNetworkImage = nil 171 | imageView.image = nil 172 | 173 | self.task?.cancel() 174 | self.task = nil 175 | } 176 | 177 | private func startDownload() { 178 | 179 | guard let image = currentUsingNetworkImage else { 180 | return 181 | } 182 | 183 | let newSize = bounds.size 184 | 185 | guard newSize.height > 0 && newSize.width > 0 else { 186 | return 187 | } 188 | 189 | // making new candidates 190 | let context = MultiplexImage.Context( 191 | targetSize: newSize, 192 | displayScale: UIScreen.main.scale 193 | ) 194 | 195 | let urls = image.makeURLs(context: context) 196 | 197 | let candidates = urls.enumerated().map { i, e in 198 | AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) 199 | } 200 | 201 | // start download 202 | 203 | let currentTask = Task { [downloader, capturedImage = image] in 204 | // this instance will be alive until finish 205 | let container = ResultContainer() 206 | let stream = await container.makeStream( 207 | candidates: candidates, 208 | downloader: downloader, 209 | displaySize: newSize 210 | ) 211 | 212 | do { 213 | for try await item in stream { 214 | 215 | // TODO: support custom animation 216 | 217 | if capturedImage == self.currentUsingNetworkImage { 218 | 219 | await MainActor.run { 220 | 221 | guard Task.isCancelled == false else { 222 | return 223 | } 224 | 225 | CATransaction.begin() 226 | let transition = CATransition() 227 | transition.duration = 0.13 228 | switch item { 229 | case .progress(let image, _): 230 | imageView.image = image 231 | case .final(let image, _): 232 | imageView.image = image 233 | } 234 | self.layer.add(transition, forKey: "transition") 235 | CATransaction.commit() 236 | } 237 | 238 | } 239 | 240 | } 241 | 242 | Log.debug(.uiKit, "download finished") 243 | } catch { 244 | // FIXME: Error handling 245 | } 246 | } 247 | 248 | self.task = currentTask 249 | } 250 | 251 | private func unloadNetworkImage() { 252 | 253 | guard let _ = currentUsingNetworkImage else { 254 | return 255 | } 256 | 257 | weak var _image = imageView.image 258 | imageView.image = nil 259 | 260 | #if DEBUG 261 | if _image != nil { 262 | Log.debug(.uiKit, "\(String(describing: _image)) was not deallocated afeter unload") 263 | } 264 | #endif 265 | 266 | } 267 | } 268 | #endif 269 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage/DownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager.swift 3 | // AsyncMultiplexImage 4 | // 5 | // Created by Muukii on 2025/01/21. 6 | // 7 | import SwiftUI 8 | 9 | actor DownloadManager { 10 | 11 | @MainActor 12 | static let shared = DownloadManager() 13 | 14 | private init() {} 15 | 16 | func start( 17 | source: MultiplexImage, 18 | candidates: [AsyncMultiplexImageCandidate], 19 | downloader: any AsyncMultiplexImageDownloader, 20 | displaySize: CGSize 21 | ) async -> sending AsyncThrowingStream { 22 | 23 | // this instance will be alive until finish 24 | let container = ResultContainer() 25 | 26 | let stream = await container.makeStream( 27 | candidates: candidates, 28 | downloader: downloader, 29 | displaySize: displaySize 30 | ) 31 | 32 | return stream 33 | 34 | } 35 | 36 | } 37 | 38 | actor ResultContainer { 39 | 40 | enum Item: Sendable { 41 | case progress(UIImage, Source) 42 | case final(UIImage, Source) 43 | 44 | var swiftUI: ItemSwiftUI.Phase { 45 | switch self { 46 | case .progress(let image, let source): 47 | return .progress(.init(uiImage: image).renderingMode(.original), source) 48 | case .final(let image, let source): 49 | return .final(.init(uiImage: image).renderingMode(.original), source) 50 | } 51 | } 52 | } 53 | 54 | struct ItemSwiftUI: Equatable { 55 | 56 | enum Phase: Equatable { 57 | case progress(Image, Source) 58 | case final(Image, Source) 59 | } 60 | 61 | let representation: ImageRepresentation 62 | let phase: Phase 63 | 64 | } 65 | 66 | private var referenceCount: UInt64 = 0 67 | 68 | private var lastCandidate: AsyncMultiplexImageCandidate? = nil 69 | 70 | private var idealImageTask: Task? 71 | private var progressImagesTask: Task? 72 | 73 | deinit { 74 | idealImageTask?.cancel() 75 | progressImagesTask?.cancel() 76 | } 77 | 78 | func incrementReference() { 79 | referenceCount += 1 80 | } 81 | 82 | func decrementReference() { 83 | referenceCount -= 1 84 | } 85 | 86 | func makeStream( 87 | candidates: [AsyncMultiplexImageCandidate], 88 | downloader: Downloader, 89 | displaySize: CGSize 90 | ) -> AsyncThrowingStream { 91 | 92 | Log.debug(.`generic`, "Load: \(candidates.map { $0.urlRequest })") 93 | 94 | return .init { [self] continuation in 95 | 96 | continuation.onTermination = { [self] termination in 97 | 98 | switch termination { 99 | case .finished, .cancelled: 100 | Task { 101 | await self.idealImageTask?.cancel() 102 | await self.progressImagesTask?.cancel() 103 | } 104 | @unknown default: 105 | break 106 | } 107 | 108 | } 109 | 110 | guard let idealCandidate = candidates.first else { 111 | continuation.finish() 112 | return 113 | } 114 | 115 | let idealImage = Task { 116 | 117 | do { 118 | let result = try await downloader.download( 119 | candidate: idealCandidate, 120 | displaySize: displaySize 121 | ) 122 | 123 | progressImagesTask?.cancel() 124 | 125 | Log.debug(.`generic`, "Loaded ideal") 126 | 127 | lastCandidate = idealCandidate 128 | continuation.yield(.final(result.image, .remote(result.metrics))) 129 | } catch { 130 | continuation.yield(with: .failure(error)) 131 | } 132 | 133 | continuation.finish() 134 | 135 | } 136 | 137 | idealImageTask = idealImage 138 | 139 | let progressCandidates = candidates.dropFirst(1) 140 | 141 | guard progressCandidates.isEmpty == false else { 142 | return 143 | } 144 | 145 | let progressImages = Task { 146 | 147 | // download images sequentially from lower image 148 | for candidate in progressCandidates.reversed() { 149 | do { 150 | 151 | guard Task.isCancelled == false else { 152 | Log.debug(.`generic`, "Cancelled progress images") 153 | return 154 | } 155 | 156 | Log.debug(.`generic`, "Load progress image => \(candidate.index)") 157 | let result = try await downloader.download( 158 | candidate: candidate, 159 | displaySize: displaySize 160 | ) 161 | 162 | guard Task.isCancelled == false else { 163 | Log.debug(.`generic`, "Cancelled progress images") 164 | return 165 | } 166 | 167 | if let lastCandidate, lastCandidate.index > candidate.index { 168 | continuation.finish() 169 | return 170 | } 171 | 172 | lastCandidate = idealCandidate 173 | 174 | let yieldResult = continuation.yield(.progress(result.image, .remote(result.metrics))) 175 | 176 | Log.debug(.`generic`, "Loaded progress image => \(candidate.index), \(yieldResult)") 177 | } catch { 178 | 179 | } 180 | } 181 | 182 | } 183 | 184 | progressImagesTask = progressImages 185 | 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Sources/AsyncMultiplexImage/MultiplexImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct MultiplexImage: Hashable, Sendable { 4 | 5 | public struct Context: ~Copyable { 6 | public let targetSize: CGSize 7 | public let displayScale: CGFloat 8 | 9 | init( 10 | targetSize: consuming CGSize, 11 | displayScale: consuming CGFloat 12 | ) { 13 | self.targetSize = targetSize 14 | self.displayScale = displayScale 15 | } 16 | } 17 | 18 | public static func == (lhs: MultiplexImage, rhs: MultiplexImage) -> Bool { 19 | lhs.identifier == rhs.identifier 20 | } 21 | 22 | public func hash(into hasher: inout Hasher) { 23 | identifier.hash(into: &hasher) 24 | } 25 | 26 | public let identifier: String 27 | 28 | private let _urlsProvider: @Sendable (borrowing Context) -> [URL] 29 | 30 | /** 31 | - Parameters: 32 | - identifier: The unique identifier of the image. 33 | - urlsProvider: The provider of the image URLs as the first item is the top priority. 34 | */ 35 | public init( 36 | identifier: String, 37 | urlsProvider: @escaping @Sendable (borrowing Context) -> [URL] 38 | ) { 39 | self.identifier = identifier 40 | self._urlsProvider = urlsProvider 41 | } 42 | 43 | public init(identifier: String, urls: [URL]) { 44 | self.init(identifier: identifier, urlsProvider: { _ in urls }) 45 | } 46 | 47 | func makeURLs(context: borrowing Context) -> [URL] { 48 | _urlsProvider(context) 49 | } 50 | 51 | } 52 | 53 | // MARK: convenience init 54 | extension MultiplexImage { 55 | 56 | public init(constant: URL) { 57 | self.identifier = constant.absoluteString 58 | self._urlsProvider = { _ in [constant] } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/AsyncMultiplexImageTests/AsyncMultiplexImageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | 4 | @testable import AsyncMultiplexImage 5 | 6 | final class swiftui_AsyncMultiplexImageTests: XCTestCase { 7 | func testExample() throws { 8 | 9 | } 10 | } 11 | --------------------------------------------------------------------------------