├── .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 |
--------------------------------------------------------------------------------