@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +285 -285
  5. package/package.json +3 -3
  6. package/pipeline/lib/redact.mjs +3 -2
  7. package/pipeline/scripts/_notices.mjs +1 -1
  8. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  9. package/pipeline/scripts/pre-commit-check.sh +4 -0
  10. package/pipeline/skills/.skill-manifest.json +69 -69
  11. package/pipeline/skills/shared/README.md +70 -70
  12. package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
  13. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
  14. package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
  15. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  16. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  17. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  18. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  19. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  20. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  21. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  22. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  23. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +345 -277
  24. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  25. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +107 -121
  26. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +145 -165
  27. package/pipeline/skills/shared/external/app-store-review/SKILL.md +306 -326
  28. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  29. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  30. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  31. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +335 -360
  32. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  33. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  34. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  35. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  36. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  37. package/pipeline/skills/shared/external/authentication/SKILL.md +277 -381
  38. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  39. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +135 -178
  40. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  41. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  42. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  43. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  44. package/pipeline/skills/shared/external/background-processing/SKILL.md +274 -384
  45. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  46. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +173 -321
  47. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  48. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  49. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  50. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  51. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  52. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  53. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  54. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  55. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  56. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +228 -376
  57. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  58. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  59. package/pipeline/skills/shared/external/core-data/SKILL.md +302 -368
  60. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  61. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  62. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  63. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  64. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  65. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  66. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  67. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  68. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  69. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  70. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  71. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  72. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  73. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  74. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  75. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  76. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  77. package/pipeline/skills/shared/external/device-integrity/SKILL.md +236 -353
  78. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  79. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  80. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  81. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  82. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  83. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  84. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  85. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  86. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  87. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  88. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  89. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  90. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  91. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  92. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  93. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  94. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  95. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  96. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  97. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  98. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  99. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  100. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  101. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  102. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  103. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  104. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  105. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  106. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  107. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  108. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  109. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  110. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  111. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  112. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  113. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  114. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  115. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
  116. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
  117. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  118. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
  119. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
  121. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  122. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
  123. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
  124. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
  125. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
  126. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
  127. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
  128. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
  129. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
  130. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +297 -267
  131. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  132. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  133. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  134. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  135. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  136. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  137. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  138. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  139. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  140. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  141. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  142. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  143. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  144. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  145. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  146. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  147. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  148. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  149. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  150. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  151. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  152. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  153. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  154. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  155. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  156. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  157. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  158. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  159. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  160. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  161. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  162. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  163. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  164. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  165. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  166. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  167. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  168. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  169. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  170. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  171. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  172. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  173. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  174. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  175. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  176. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  177. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +302 -241
  178. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  179. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  180. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  181. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  182. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  183. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  184. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  185. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  186. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  187. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  188. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  189. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  190. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  191. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  192. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +304 -351
  193. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  194. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  195. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  196. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  197. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  198. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  199. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  200. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  201. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  202. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  203. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  204. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  205. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  206. package/pipeline/skills/shared/external/swift-security/SKILL.md +183 -162
  207. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  208. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  209. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +411 -476
  210. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  211. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  212. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  213. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  214. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  215. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  216. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +375 -491
  217. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  218. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  219. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  220. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +397 -457
  221. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  222. package/pipeline/skills/shared/external/swift-testing/SKILL.md +191 -175
  223. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  224. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +81 -84
  225. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  226. package/pipeline/skills/shared/external/swiftdata/SKILL.md +394 -256
  227. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  228. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  229. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  230. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  231. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  232. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  233. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  234. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  235. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  236. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  237. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  238. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  239. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  240. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  241. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  242. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  243. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  244. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  245. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  246. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  247. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  248. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  249. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  250. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  251. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +201 -168
  252. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  253. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +134 -133
  254. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  255. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +111 -138
  256. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  257. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  258. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  259. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  260. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  261. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  262. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  263. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  264. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  265. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  266. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  267. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  268. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  269. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  270. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  271. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  272. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  273. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  274. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  275. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  276. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  277. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  278. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  279. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  280. package/pipeline/skills/shared/external/weatherkit/SKILL.md +160 -315
  281. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  282. package/pipeline/skills/shared/external/widgetkit/SKILL.md +224 -288
  283. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +416 -719
  284. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
@@ -1,869 +1,525 @@
1
- # Image Loading and Caching Patterns
1
+ # Loading, caching and downsampling images
2
2
 
3
- Complete patterns for efficient image handling in iOS apps, from basic AsyncImage usage through production-ready caching and loading pipelines. All patterns use modern Swift async/await and target iOS 26 with Swift 6.3, backward-compatible to iOS 16 unless noted.
3
+ Everything here uses async/await and targets iOS 26 and Swift 6.3.
4
+ The samples need iOS 17 or later because the models use `@Observable`.
4
5
 
5
6
  ## Contents
6
- - AsyncImage Patterns
7
- - NSCache-Based In-Memory Cache
8
- - URLCache-Based Disk Caching
9
- - Image Downsampling with CGImageSource
10
- - Image Prefetching for Lists and Grids
11
- - HEIF/HEIC Handling
12
- - Compression Before Upload
13
- - Memory Budget Management
14
- - Complete Image Loading Pipeline
15
7
 
16
- ## AsyncImage Patterns
8
+ - [AsyncImage](#asyncimage)
9
+ - [In-memory cache on NSCache](#in-memory-cache-on-nscache)
10
+ - [Disk cache with URLCache](#disk-cache-with-urlcache)
11
+ - [Downsampling at decode time](#downsampling-at-decode-time)
12
+ - [Prefetching](#prefetching)
13
+ - [HEIF and HEIC](#heif-and-heic)
14
+ - [Shrinking before upload](#shrinking-before-upload)
15
+ - [Staying inside a memory budget](#staying-inside-a-memory-budget)
16
+ - [One loader for everything](#one-loader-for-everything)
17
+ - [Which layer for which job](#which-layer-for-which-job)
17
18
 
18
- AsyncImage (iOS 15+) provides built-in async image loading from a URL. Suitable for simple cases but has significant limitations for production use.
19
+ ## AsyncImage
19
20
 
20
- ### Basic Usage
21
+ `AsyncImage` (iOS 15+) is fine for a handful of images and weak for anything
22
+ heavier.
21
23
 
22
24
  ```swift
23
- AsyncImage(url: URL(string: "https://example.com/photo.jpg"))
24
- ```
25
-
26
- ### Phase Handling
25
+ AsyncImage(url: logoURL)
27
26
 
28
- ```swift
29
- AsyncImage(url: imageURL) { phase in
27
+ AsyncImage(url: bannerURL) { phase in
30
28
  switch phase {
31
29
  case .empty:
32
30
  ProgressView()
33
- .frame(width: 200, height: 200)
34
- case .success(let image):
35
- image
36
- .resizable()
37
- .aspectRatio(contentMode: .fill)
38
- .frame(width: 200, height: 200)
39
- .clipped()
31
+ case .success(let banner):
32
+ banner.resizable().aspectRatio(contentMode: .fill)
40
33
  case .failure:
41
- Image(systemName: "photo")
42
- .font(.largeTitle)
43
- .foregroundStyle(.secondary)
44
- .frame(width: 200, height: 200)
34
+ Image(systemName: "photo").foregroundStyle(.secondary)
45
35
  @unknown default:
46
36
  EmptyView()
47
37
  }
48
38
  }
49
- ```
50
-
51
- ### Custom Transition
39
+ .frame(width: 200, height: 200)
40
+ .clipped()
52
41
 
53
- ```swift
54
- AsyncImage(url: imageURL, transaction: Transaction(animation: .easeIn(duration: 0.3))) { phase in
55
- switch phase {
56
- case .success(let image):
57
- image
58
- .resizable()
59
- .aspectRatio(contentMode: .fill)
60
- .transition(.opacity)
61
- default:
62
- Color.secondary.opacity(0.2)
42
+ AsyncImage(url: coverURL, transaction: .init(animation: .easeIn(duration: 0.3))) { phase in
43
+ if let cover = phase.image {
44
+ cover.resizable().scaledToFit().transition(.opacity)
45
+ } else {
46
+ Color.gray.opacity(0.2)
63
47
  }
64
48
  }
65
- .frame(width: 200, height: 200)
66
- .clipShape(.rect(cornerRadius: 12))
67
49
  ```
68
50
 
69
- ### Limitations
51
+ What it cannot do:
70
52
 
71
- AsyncImage has several shortcomings for production apps:
53
+ - Cache across redraws. When the view is rebuilt, the image downloads again.
54
+ - Prefetch rows that are about to scroll into view.
55
+ - Use your own `URLSession`. It rides the shared session with no say over cache
56
+ policy.
57
+ - Expose the downloaded bytes, so no processing, downsampling or persistence.
58
+ - Cancel on your terms. Its work lives and dies with the view.
72
59
 
73
- - **No caching across redraws**: images re-download when the view is recreated.
74
- - **No prefetching**: cannot load images ahead of scroll position.
75
- - **No custom URLSession**: uses the shared session with no cache policy control.
76
- - **No access to raw data**: cannot process, downsample, or persist the image data.
77
- - **No cancellation control**: tied entirely to the view lifecycle.
60
+ Past simple, low-volume display, build a loading pipeline like the ones below.
78
61
 
79
- For anything beyond simple, low-volume image display, use a custom loading pipeline.
62
+ ## In-memory cache on NSCache
80
63
 
81
- ---
82
-
83
- ## NSCache-Based In-Memory Cache
84
-
85
- An actor-isolated in-memory image cache backed by NSCache. Thread-safe and automatically evicts entries under memory pressure.
64
+ An actor around `NSCache` is safe to call from anywhere, and `NSCache` drops
65
+ entries on its own when memory gets tight. The actor also coalesces requests:
66
+ two callers asking for the same URL share one download.
86
67
 
87
68
  ```swift
88
69
  import UIKit
89
70
 
90
- actor ImageCache {
91
- static let shared = ImageCache()
71
+ actor PosterCache {
72
+ static let shared = PosterCache()
92
73
 
93
- private let cache = NSCache<NSString, UIImage>()
94
- private var inFlightTasks: [URL: Task<UIImage?, Never>] = [:]
74
+ private let store: NSCache<NSString, UIImage> = {
75
+ let cache = NSCache<NSString, UIImage>()
76
+ cache.countLimit = 100
77
+ cache.totalCostLimit = 50 * 1024 * 1024
78
+ return cache
79
+ }()
80
+ private var inFlight: [URL: Task<UIImage?, Never>] = [:]
95
81
 
96
- init(countLimit: Int = 100, totalCostLimit: Int = 50 * 1024 * 1024) {
97
- cache.countLimit = countLimit
98
- cache.totalCostLimit = totalCostLimit // 50 MB default
99
- }
82
+ private func key(_ address: URL) -> NSString { address.absoluteString as NSString }
100
83
 
101
- func image(for url: URL) -> UIImage? {
102
- cache.object(forKey: url.absoluteString as NSString)
84
+ func image(for address: URL) -> UIImage? {
85
+ store.object(forKey: key(address))
103
86
  }
104
87
 
105
- func store(_ image: UIImage, for url: URL) {
106
- let cost = image.jpegData(compressionQuality: 1.0)?.count ?? 0
107
- cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
88
+ func store(_ picture: UIImage, for address: URL) {
89
+ store.setObject(picture, forKey: key(address), cost: Self.bitmapBytes(picture))
108
90
  }
109
91
 
110
- func removeImage(for url: URL) {
111
- cache.removeObject(forKey: url.absoluteString as NSString)
92
+ func removeImage(for address: URL) {
93
+ store.removeObject(forKey: key(address))
112
94
  }
113
95
 
114
96
  func removeAll() {
115
- cache.removeAllObjects()
116
- inFlightTasks.removeAll()
117
- }
118
-
119
- /// Fetch an image with request coalescing. Multiple callers for the same URL
120
- /// share a single network request.
121
- func fetch(from url: URL, session: URLSession = .shared) async -> UIImage? {
122
- // Return cached image immediately
123
- if let cached = image(for: url) {
124
- return cached
125
- }
126
-
127
- // Coalesce duplicate in-flight requests
128
- if let existingTask = inFlightTasks[url] {
129
- return await existingTask.value
130
- }
131
-
132
- let task = Task<UIImage?, Never> {
133
- defer { inFlightTasks[url] = nil }
134
-
135
- guard let (data, response) = try? await session.data(from: url),
136
- let httpResponse = response as? HTTPURLResponse,
137
- httpResponse.statusCode == 200,
138
- let image = UIImage(data: data) else {
139
- return nil
140
- }
141
-
142
- store(image, for: url)
143
- return image
97
+ store.removeAllObjects()
98
+ inFlight.values.forEach { $0.cancel() }
99
+ inFlight.removeAll()
100
+ }
101
+
102
+ func fetch(from address: URL, session: URLSession = .shared) async -> UIImage? {
103
+ if let hit = image(for: address) { return hit }
104
+ if let running = inFlight[address] { return await running.value }
105
+ let download = Task { () -> UIImage? in
106
+ guard let (body, reply) = try? await session.data(from: address),
107
+ (reply as? HTTPURLResponse)?.statusCode == 200 else { return nil }
108
+ return UIImage(data: body)
144
109
  }
110
+ inFlight[address] = download
111
+ defer { inFlight[address] = nil }
112
+ let result = await download.value
113
+ if let result { store(result, for: address) }
114
+ return result
115
+ }
145
116
 
146
- inFlightTasks[url] = task
147
- return await task.value
117
+ static func bitmapBytes(_ image: UIImage) -> Int {
118
+ guard let bitmap = image.cgImage else { return 0 }
119
+ return bitmap.bytesPerRow * bitmap.height
148
120
  }
149
121
  }
150
122
  ```
151
123
 
152
- ### Usage in SwiftUI
124
+ Price each entry by its decoded bitmap size. Encoding a JPEG just to measure an
125
+ entry is slow and reports compressed bytes, which is not what the cache holds
126
+ in memory.
153
127
 
154
128
  ```swift
155
- struct CachedImageView: View {
129
+ struct PosterView: View {
156
130
  let url: URL
157
- @State private var image: UIImage?
131
+ @State private var poster: UIImage?
158
132
 
159
133
  var body: some View {
160
134
  Group {
161
- if let image {
162
- Image(uiImage: image)
163
- .resizable()
164
- .aspectRatio(contentMode: .fill)
165
- } else {
166
- Color.secondary.opacity(0.2)
167
- .overlay(ProgressView())
168
- }
169
- }
170
- .task(id: url) {
171
- image = await ImageCache.shared.fetch(from: url)
135
+ if let poster { Image(uiImage: poster).resizable().scaledToFill() } else { ProgressView() }
172
136
  }
137
+ .task(id: url) { poster = await PosterCache.shared.fetch(from: url) }
173
138
  }
174
139
  }
175
140
  ```
176
141
 
177
- ---
178
-
179
- ## URLCache-Based Disk Caching
142
+ ## Disk cache with URLCache
180
143
 
181
- Configure URLCache for persistent image caching that survives app restarts. URLCache handles HTTP cache headers automatically.
182
-
183
- ### Configuring a Dedicated URLSession
144
+ `URLCache` survives relaunches and honours the server's HTTP cache headers with
145
+ no extra code. Give images their own session and cache:
184
146
 
185
147
  ```swift
186
- enum ImageSessionConfiguration {
187
- static func makeSession() -> URLSession {
188
- let config = URLSessionConfiguration.default
189
-
190
- // 50 MB memory / 200 MB disk
191
- config.urlCache = URLCache(
192
- memoryCapacity: 50 * 1024 * 1024,
193
- diskCapacity: 200 * 1024 * 1024,
194
- directory: cacheDirectory
195
- )
196
-
197
- config.requestCachePolicy = .returnCacheDataElseLoad
198
- config.httpMaximumConnectionsPerHost = 6
199
-
200
- return URLSession(configuration: config)
201
- }
202
-
203
- private static var cacheDirectory: URL {
204
- FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
205
- .appendingPathComponent("ImageCache", isDirectory: true)
206
- }
148
+ enum ImageNetwork {
149
+ static let session: URLSession = {
150
+ let folder = URL.cachesDirectory.appending(path: "ImageCache")
151
+ let megabyte = 1024 * 1024
152
+ let setup = URLSessionConfiguration.default
153
+ setup.urlCache = URLCache(memoryCapacity: 50 * megabyte,
154
+ diskCapacity: 200 * megabyte,
155
+ directory: folder)
156
+ setup.requestCachePolicy = .returnCacheDataElseLoad
157
+ setup.httpMaximumConnectionsPerHost = 6
158
+ return URLSession(configuration: setup)
159
+ }()
207
160
  }
208
- ```
209
161
 
210
- ### Cache-Aware Request
162
+ let cached = URLRequest(url: artURL, cachePolicy: .returnCacheDataElseLoad)
163
+ let fresh = URLRequest(url: artURL, cachePolicy: .reloadIgnoringLocalCacheData)
211
164
 
212
- ```swift
213
- func cachedImageData(from url: URL, session: URLSession) async throws -> Data {
214
- let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
215
- let (data, _) = try await session.data(for: request)
216
- return data
217
- }
165
+ ImageNetwork.session.configuration.urlCache?.removeAllCachedResponses()
166
+ ImageNetwork.session.configuration.urlCache?.removeCachedResponse(for: cached)
218
167
  ```
219
168
 
220
- ### Force Refresh (Bypass Cache)
221
-
222
- ```swift
223
- func refreshImageData(from url: URL, session: URLSession) async throws -> Data {
224
- let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
225
- let (data, _) = try await session.data(for: request)
226
- return data
227
- }
228
- ```
169
+ ## Downsampling at decode time
229
170
 
230
- ### Cache Cleanup
231
-
232
- ```swift
233
- func cleanImageCache(session: URLSession) {
234
- session.configuration.urlCache?.removeAllCachedResponses()
235
- }
236
-
237
- func removeCachedImage(for url: URL, session: URLSession) {
238
- let request = URLRequest(url: url)
239
- session.configuration.urlCache?.removeCachedResponse(for: request)
240
- }
241
- ```
242
-
243
- ---
244
-
245
- ## Image Downsampling with CGImageSource
246
-
247
- Loading a full-resolution image into memory then scaling it in the view wastes significant memory. Downsampling at decode time creates a smaller bitmap directly.
248
-
249
- ### Downsample Function
171
+ Decoding at full size and letting the view scale it down wastes memory.
172
+ `CGImageSource` can decode straight into a small bitmap instead.
250
173
 
251
174
  ```swift
252
175
  import ImageIO
253
- import UIKit
254
176
 
255
- func downsample(data: Data, to pointSize: CGSize, scale: CGFloat = UITraitCollection.current.displayScale) -> UIImage? {
256
- let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
257
-
258
- let options: [CFString: Any] = [
259
- kCGImageSourceCreateThumbnailFromImageAlways: true,
260
- kCGImageSourceShouldCacheImmediately: true,
261
- kCGImageSourceCreateThumbnailWithTransform: true,
262
- kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
263
- ]
264
-
265
- guard let source = CGImageSourceCreateWithData(data as CFData, nil),
266
- let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
267
- return nil
177
+ enum Shrinker {
178
+ static func fromData(_ bytes: Data, maxPixels: CGFloat) -> UIImage? {
179
+ guard let reader = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
180
+ return render(reader, maxPixels: maxPixels)
268
181
  }
269
182
 
270
- return UIImage(cgImage: cgImage)
271
- }
272
- ```
273
-
274
- ### Downsample from URL
275
-
276
- ```swift
277
- func downsample(url: URL, to pointSize: CGSize, scale: CGFloat = UITraitCollection.current.displayScale) -> UIImage? {
278
- let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
279
-
280
- let options: [CFString: Any] = [
281
- kCGImageSourceCreateThumbnailFromImageAlways: true,
282
- kCGImageSourceShouldCacheImmediately: true,
283
- kCGImageSourceCreateThumbnailWithTransform: true,
284
- kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
285
- ]
286
-
287
- guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
288
- let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
289
- return nil
183
+ static func fromFile(_ file: URL, maxPixels: CGFloat) -> UIImage? {
184
+ guard let reader = CGImageSourceCreateWithURL(file as CFURL, nil) else { return nil }
185
+ return render(reader, maxPixels: maxPixels)
290
186
  }
291
187
 
292
- return UIImage(cgImage: cgImage)
188
+ private static func render(_ reader: CGImageSource, maxPixels: CGFloat) -> UIImage? {
189
+ let plan: [CFString: Any] = [
190
+ kCGImageSourceThumbnailMaxPixelSize: maxPixels,
191
+ kCGImageSourceShouldCacheImmediately: true,
192
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
193
+ kCGImageSourceCreateThumbnailWithTransform: true,
194
+ ]
195
+ return CGImageSourceCreateThumbnailAtIndex(reader, 0, plan as CFDictionary).map(UIImage.init(cgImage:))
196
+ }
293
197
  }
294
198
  ```
295
199
 
296
- ### When to Downsample
200
+ Downsample when:
297
201
 
298
- - Displaying thumbnails in a list or grid (a 4032x3024 photo for a 100x100 cell wastes ~46 MB per image).
299
- - User-selected photos from the photo library.
300
- - Images fetched from a server that are larger than the display size.
301
- - Any time the source image dimensions exceed 2x the display point size.
202
+ - showing thumbnails in lists or grids (a 4032 x 3024 photo in a 100 x 100 cell
203
+ wastes roughly 46 MB per cell otherwise);
204
+ - showing photos the person picked from the library;
205
+ - the server sends images larger than they will be drawn;
206
+ - as a rule of thumb, the source is more than twice the display size in points.
302
207
 
303
- ### Memory Savings
208
+ Decoded memory at 3x scale, downsampled against a full decode:
304
209
 
305
- | Source Size | Display Size | Without Downsampling | With Downsampling |
210
+ | Source | Drawn at | Downsampled | Full decode |
306
211
  |---|---|---|---|
307
- | 4032x3024 | 100x100 pt @3x | ~46 MB | ~0.35 MB |
308
- | 4032x3024 | 300x300 pt @3x | ~46 MB | ~3.1 MB |
309
- | 1920x1080 | 100x100 pt @3x | ~7.9 MB | ~0.35 MB |
310
-
311
- ---
212
+ | 4032 x 3024 | 100 x 100 pt | ~0.35 MB | ~46 MB |
213
+ | 4032 x 3024 | 300 x 300 pt | ~3.1 MB | ~46 MB |
214
+ | 1920 x 1080 | 100 x 100 pt | ~0.35 MB | ~7.9 MB |
312
215
 
313
- ## Image Prefetching for Lists and Grids
216
+ ## Prefetching
314
217
 
315
- Prefetch images before they scroll into view. Works with both UICollectionView data source prefetching and SwiftUI List.
316
-
317
- ### Prefetch Coordinator
218
+ Start downloads a little before cells appear. This pairs with
219
+ `UICollectionView` prefetching and with SwiftUI grids alike.
318
220
 
319
221
  ```swift
320
- @Observable
321
- @MainActor
322
- final class ImagePrefetcher {
323
- private let cache: ImageCache
324
- private let session: URLSession
325
- private var prefetchTasks: [URL: Task<Void, Never>] = [:]
326
-
327
- init(cache: ImageCache = .shared,
328
- session: URLSession = ImageSessionConfiguration.makeSession()) {
329
- self.cache = cache
330
- self.session = session
331
- }
222
+ @Observable @MainActor
223
+ final class ArtPrefetcher {
224
+ private var jobs: [URL: Task<Void, Never>] = [:]
332
225
 
333
226
  func prefetch(urls: [URL]) {
334
- for url in urls {
335
- guard prefetchTasks[url] == nil else { continue }
336
-
337
- prefetchTasks[url] = Task {
338
- _ = await cache.fetch(from: url, session: session)
339
- prefetchTasks[url] = nil
227
+ for url in urls where jobs[url] == nil {
228
+ jobs[url] = Task {
229
+ _ = await PosterCache.shared.fetch(from: url)
230
+ jobs[url] = nil
340
231
  }
341
232
  }
342
233
  }
343
234
 
344
235
  func cancelPrefetch(urls: [URL]) {
345
236
  for url in urls {
346
- prefetchTasks[url]?.cancel()
347
- prefetchTasks[url] = nil
237
+ jobs[url]?.cancel()
238
+ jobs[url] = nil
348
239
  }
349
240
  }
350
241
 
351
242
  func cancelAll() {
352
- prefetchTasks.values.forEach { $0.cancel() }
353
- prefetchTasks.removeAll()
243
+ jobs.values.forEach { $0.cancel() }
244
+ jobs.removeAll()
354
245
  }
355
246
  }
356
- ```
357
-
358
- ### SwiftUI Integration with ScrollView and LazyVGrid
359
-
360
- ```swift
361
- struct PhotoGrid: View {
362
- let photos: [Photo]
363
- @State private var prefetcher = ImagePrefetcher()
364
247
 
248
+ struct ArtGrid: View {
249
+ let urls: [URL]
250
+ @State private var prefetcher = ArtPrefetcher()
365
251
  private let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]
366
252
 
367
253
  var body: some View {
368
254
  ScrollView {
369
- LazyVGrid(columns: columns, spacing: 2) {
370
- ForEach(photos) { photo in
371
- CachedImageView(url: photo.thumbnailURL)
372
- .aspectRatio(1, contentMode: .fill)
373
- .clipped()
255
+ LazyVGrid(columns: columns) {
256
+ ForEach(Array(urls.enumerated()), id: \.offset) { index, url in
257
+ PosterView(url: url)
258
+ .frame(height: 100)
374
259
  .onAppear {
375
- prefetchNearby(photo)
260
+ let ahead = urls[(index + 1)..<min(index + 11, urls.count)]
261
+ prefetcher.prefetch(urls: Array(ahead))
376
262
  }
377
263
  }
378
264
  }
379
265
  }
380
- .onDisappear {
381
- prefetcher.cancelAll()
382
- }
383
- }
384
-
385
- private func prefetchNearby(_ photo: Photo) {
386
- guard let index = photos.firstIndex(where: { $0.id == photo.id }) else { return }
387
- let prefetchRange = (index + 1)..<min(index + 10, photos.count)
388
- let urls = prefetchRange.map { photos[$0].thumbnailURL }
389
- prefetcher.prefetch(urls: urls)
266
+ .onDisappear { prefetcher.cancelAll() }
390
267
  }
391
268
  }
392
269
  ```
393
270
 
394
- ### UICollectionView Prefetching (UIKit Interop)
271
+ In UIKit, a `UICollectionViewDataSourcePrefetching` object maps index paths to
272
+ URLs in `collectionView(_:prefetchItemsAt:)` and calls `cancelPrefetch` from
273
+ `collectionView(_:cancelPrefetchingForItemsAt:)`.
395
274
 
396
- ```swift
397
- final class PhotoCollectionPrefetcher: NSObject, UICollectionViewDataSourcePrefetching {
398
- private let prefetcher = ImagePrefetcher()
399
- private let photos: [Photo]
275
+ ## HEIF and HEIC
400
276
 
401
- init(photos: [Photo]) {
402
- self.photos = photos
403
- }
404
-
405
- func collectionView(_ collectionView: UICollectionView,
406
- prefetchItemsAt indexPaths: [IndexPath]) {
407
- let urls = indexPaths.map { photos[$0.item].thumbnailURL }
408
- prefetcher.prefetch(urls: urls)
409
- }
410
-
411
- func collectionView(_ collectionView: UICollectionView,
412
- cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
413
- let urls = indexPaths.map { photos[$0.item].thumbnailURL }
414
- prefetcher.cancelPrefetch(urls: urls)
415
- }
416
- }
417
- ```
418
-
419
- ---
420
-
421
- ## HEIF/HEIC Handling
422
-
423
- HEIF (High Efficiency Image Format) is the default camera format on modern iPhones. Handle detection, display, and conversion.
424
-
425
- ### Detection
277
+ HEIF is what current iPhones shoot by default.
426
278
 
427
279
  ```swift
428
280
  import UniformTypeIdentifiers
429
281
 
430
- func isHEIF(data: Data) -> Bool {
431
- guard data.count >= 12 else { return false }
432
- // Check for 'ftyp' box at byte 4
433
- let ftypRange = data[4..<8]
434
- return ftypRange.elementsEqual("ftyp".utf8)
435
- }
436
-
437
- func isHEIF(url: URL) -> Bool {
438
- guard let type = UTType(filenameExtension: url.pathExtension) else { return false }
439
- return type.conforms(to: .heif) || type.conforms(to: .heic)
440
- }
441
- ```
442
-
443
- ### Conversion to JPEG
444
-
445
- ```swift
446
- func convertHEICToJPEG(data: Data, compressionQuality: CGFloat = 0.9) -> Data? {
447
- guard let image = UIImage(data: data) else { return nil }
448
- return image.jpegData(compressionQuality: compressionQuality)
449
- }
450
- ```
451
-
452
- ### Conversion to PNG
453
-
454
- ```swift
455
- func convertHEICToPNG(data: Data) -> Data? {
456
- guard let image = UIImage(data: data) else { return nil }
457
- return image.pngData()
458
- }
459
- ```
460
-
461
- ### Conversion with CGImageDestination (More Control)
462
-
463
- ```swift
464
- import ImageIO
465
-
466
- func convertHEICToJPEG(sourceData: Data,
467
- quality: CGFloat = 0.9,
468
- preserveMetadata: Bool = true) -> Data? {
469
- guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil),
470
- let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
471
- return nil
282
+ enum HEIFTools {
283
+ static func looksLikeHEIF(_ bytes: Data) -> Bool {
284
+ guard bytes.count >= 12 else { return false }
285
+ return bytes[bytes.startIndex + 4 ..< bytes.startIndex + 8] == Data("ftyp".utf8)
472
286
  }
473
287
 
474
- let mutableData = NSMutableData()
475
- guard let destination = CGImageDestinationCreateWithData(
476
- mutableData, UTType.jpeg.identifier as CFString, 1, nil
477
- ) else {
478
- return nil
288
+ static func isHEIF(fileExtension: String) -> Bool {
289
+ guard let kind = UTType(filenameExtension: fileExtension) else { return false }
290
+ return kind.conforms(to: .heif) || kind.conforms(to: .heic)
479
291
  }
480
292
 
481
- var options: [CFString: Any] = [
482
- kCGImageDestinationLossyCompressionQuality: quality
483
- ]
293
+ static func jpeg(from photo: UIImage, quality: CGFloat = 0.9) -> Data? {
294
+ photo.jpegData(compressionQuality: quality)
295
+ }
484
296
 
485
- // Preserve EXIF, GPS, and other metadata
486
- if preserveMetadata,
487
- let metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) {
488
- options[kCGImageDestinationMergeMetadata] = true
489
- CGImageDestinationAddImage(destination, cgImage, metadata)
490
- } else {
491
- CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
297
+ static func png(from photo: UIImage) -> Data? {
298
+ photo.pngData()
492
299
  }
493
300
 
494
- guard CGImageDestinationFinalize(destination) else { return nil }
495
- return mutableData as Data
301
+ static func jpegKeepingMetadata(_ heif: Data, quality: CGFloat = 0.9) -> Data? {
302
+ guard let reader = CGImageSourceCreateWithData(heif as CFData, nil),
303
+ let frame = CGImageSourceCreateImageAtIndex(reader, 0, nil) else { return nil }
304
+ let sink = NSMutableData()
305
+ let jpegID = UTType.jpeg.identifier as CFString
306
+ guard let writer = CGImageDestinationCreateWithData(sink, jpegID, 1, nil)
307
+ else { return nil }
308
+ var properties = (CGImageSourceCopyPropertiesAtIndex(reader, 0, nil) as? [CFString: Any]) ?? [:]
309
+ properties[kCGImageDestinationLossyCompressionQuality] = quality
310
+ properties[kCGImageDestinationMergeMetadata] = true
311
+ CGImageDestinationAddImage(writer, frame, properties as CFDictionary)
312
+ return CGImageDestinationFinalize(writer) ? sink as Data : nil
313
+ }
496
314
  }
497
315
  ```
498
316
 
499
- ---
500
-
501
- ## Compression Before Upload
317
+ The `ftyp` check is a cheap first look at the container box; confirm with
318
+ `CGImageSourceGetType` when it matters. The metadata-preserving path copies the
319
+ source properties (EXIF, GPS) into the destination; the `UIImage` paths drop
320
+ them.
502
321
 
503
- Reduce file size before uploading to a server. Balance quality and size based on the use case.
504
-
505
- ### JPEG Compression with Target Size
322
+ ## Shrinking before upload
506
323
 
507
324
  ```swift
508
- func compressForUpload(image: UIImage,
509
- maxBytes: Int = 1_000_000,
510
- initialQuality: CGFloat = 0.9) -> Data? {
511
- var quality = initialQuality
512
-
513
- while quality > 0.1 {
514
- guard let data = image.jpegData(compressionQuality: quality) else { return nil }
515
- if data.count <= maxBytes {
516
- return data
325
+ enum UploadPrep {
326
+ static func jpeg(_ photo: UIImage, under maxBytes: Int = 1_000_000) -> Data? {
327
+ var quality: CGFloat = 0.9
328
+ while quality > 0.1 {
329
+ if let encoded = photo.jpegData(compressionQuality: quality), encoded.count <= maxBytes {
330
+ return encoded
331
+ }
332
+ quality -= 0.1
517
333
  }
518
- quality -= 0.1
519
- }
520
-
521
- // Final attempt at minimum quality
522
- return image.jpegData(compressionQuality: 0.1)
523
- }
524
- ```
525
-
526
- ### Resize and Compress
527
-
528
- ```swift
529
- func resizeAndCompress(image: UIImage,
530
- maxDimension: CGFloat = 1920,
531
- compressionQuality: CGFloat = 0.8) -> Data? {
532
- let size = image.size
533
- let scale: CGFloat
534
-
535
- if max(size.width, size.height) > maxDimension {
536
- scale = maxDimension / max(size.width, size.height)
537
- } else {
538
- scale = 1.0
334
+ return photo.jpegData(compressionQuality: 0.1)
539
335
  }
540
336
 
541
- let newSize = CGSize(width: size.width * scale, height: size.height * scale)
542
-
543
- let renderer = UIGraphicsImageRenderer(size: newSize)
544
- let resized = renderer.image { _ in
545
- image.draw(in: CGRect(origin: .zero, size: newSize))
337
+ static func resized(_ photo: UIImage, longestSide: CGFloat = 1920) -> Data? {
338
+ let ratio = min(1, longestSide / max(photo.size.width, photo.size.height))
339
+ let target = CGSize(width: photo.size.width * ratio, height: photo.size.height * ratio)
340
+ let redrawn = UIGraphicsImageRenderer(size: target).image { _ in
341
+ photo.draw(in: CGRect(x: 0, y: 0, width: target.width, height: target.height))
342
+ }
343
+ return redrawn.jpegData(compressionQuality: 0.8)
546
344
  }
547
345
 
548
- return resized.jpegData(compressionQuality: compressionQuality)
549
- }
550
- ```
551
-
552
- ### HEIF Compression (Smaller Files)
553
-
554
- ```swift
555
- func compressAsHEIF(image: UIImage, quality: CGFloat = 0.8) -> Data? {
556
- guard let cgImage = image.cgImage else { return nil }
557
-
558
- let mutableData = NSMutableData()
559
- guard let destination = CGImageDestinationCreateWithData(
560
- mutableData, UTType.heic.identifier as CFString, 1, nil
561
- ) else {
562
- return nil
346
+ static func heic(_ photo: UIImage, level: CGFloat = 0.8) -> Data? {
347
+ guard let bitmap = photo.cgImage else { return nil }
348
+ let sink = NSMutableData()
349
+ let heicID = UTType.heic.identifier as CFString
350
+ guard let writer = CGImageDestinationCreateWithData(sink, heicID, 1, nil)
351
+ else { return nil }
352
+ CGImageDestinationAddImage(writer, bitmap, [kCGImageDestinationLossyCompressionQuality: level] as CFDictionary)
353
+ return CGImageDestinationFinalize(writer) ? sink as Data : nil
563
354
  }
564
-
565
- let options: [CFString: Any] = [
566
- kCGImageDestinationLossyCompressionQuality: quality
567
- ]
568
-
569
- CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
570
- guard CGImageDestinationFinalize(destination) else { return nil }
571
-
572
- return mutableData as Data
573
355
  }
574
356
  ```
575
357
 
576
- ---
577
-
578
- ## Memory Budget Management
358
+ Pick the trade-off per use: the quality ladder steps down from 0.9 in 0.1
359
+ steps until the payload fits, with 0.1 as the floor; resizing caps the long
360
+ edge first; HEIC gives smaller files at the same visual quality.
579
361
 
580
- Monitor and respond to memory pressure to keep your image pipeline stable.
581
-
582
- ### Memory Warning Observer
362
+ ## Staying inside a memory budget
583
363
 
584
364
  ```swift
585
- @Observable
586
- @MainActor
587
- final class MemoryMonitor {
588
- var isUnderPressure = false
365
+ @Observable @MainActor
366
+ final class PressureWatch {
367
+ private(set) var underPressure = false
368
+ @ObservationIgnored private var listener: Task<Void, Never>?
369
+ private let cooldown: Duration = .seconds(10)
589
370
 
590
371
  init() {
591
- NotificationCenter.default.addObserver(
592
- forName: UIApplication.didReceiveMemoryWarningNotification,
593
- object: nil,
594
- queue: .main
595
- ) { [weak self] _ in
596
- self?.handleMemoryWarning()
372
+ listener = Task { [weak self] in
373
+ let warnings = NotificationCenter.default.notifications(named: UIApplication.didReceiveMemoryWarningNotification)
374
+ for await _ in warnings { self?.react() }
597
375
  }
598
376
  }
599
377
 
600
- private func handleMemoryWarning() {
601
- isUnderPressure = true
602
-
603
- Task {
604
- await ImageCache.shared.removeAll()
605
- }
606
-
607
- // Reset after a delay
378
+ private func react() {
379
+ underPressure = true
608
380
  Task {
609
- try? await Task.sleep(for: .seconds(10))
610
- isUnderPressure = false
381
+ await PosterCache.shared.removeAll()
382
+ try? await Task.sleep(for: cooldown)
383
+ underPressure = false
611
384
  }
612
385
  }
613
386
  }
614
- ```
615
387
 
616
- ### Process Memory Usage
617
-
618
- ```swift
619
- func currentMemoryUsageMB() -> Double {
620
- var info = mach_task_basic_info()
621
- var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
622
- let result = withUnsafeMutablePointer(to: &info) {
623
- $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
624
- task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
388
+ func residentMemory() -> UInt64 {
389
+ var usage = mach_task_basic_info()
390
+ var words = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size / MemoryLayout<natural_t>.size)
391
+ let slotCount = Int(words)
392
+ let status = withUnsafeMutablePointer(to: &usage) { raw in
393
+ raw.withMemoryRebound(to: integer_t.self, capacity: slotCount) { slots in
394
+ task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), slots, &words)
625
395
  }
626
396
  }
627
- guard result == KERN_SUCCESS else { return 0 }
628
- return Double(info.resident_size) / (1024 * 1024)
397
+ return status == KERN_SUCCESS ? usage.resident_size : 0
629
398
  }
630
399
  ```
631
400
 
632
- ### Adaptive Cache Sizing
401
+ Size the cache from the device instead of a constant:
633
402
 
634
403
  ```swift
635
- actor AdaptiveImageCache {
636
- private let cache = NSCache<NSString, UIImage>()
637
-
638
- init() {
639
- let totalMemory = ProcessInfo.processInfo.physicalMemory
640
- // Use at most 10% of physical memory for image cache
641
- let budgetBytes = Int(totalMemory / 10)
642
- cache.totalCostLimit = budgetBytes
643
- cache.countLimit = 200
644
- }
645
-
646
- func image(for url: URL) -> UIImage? {
647
- cache.object(forKey: url.absoluteString as NSString)
648
- }
649
-
650
- func store(_ image: UIImage, for url: URL) {
651
- let cost = cgImageMemorySize(image)
652
- cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
653
- }
654
-
655
- /// Estimate the decoded bitmap size in bytes.
656
- private func cgImageMemorySize(_ image: UIImage) -> Int {
657
- guard let cgImage = image.cgImage else { return 0 }
658
- return cgImage.bytesPerRow * cgImage.height
659
- }
660
-
661
- func purge() {
662
- cache.removeAllObjects()
663
- }
664
- }
404
+ let adaptive = NSCache<NSString, UIImage>()
405
+ adaptive.totalCostLimit = Int(ProcessInfo.processInfo.physicalMemory / 10)
406
+ adaptive.countLimit = 200
665
407
  ```
666
408
 
667
- ---
668
-
669
- ## Complete Image Loading Pipeline
409
+ Charge each entry `cgImage.bytesPerRow * cgImage.height`, its real decoded
410
+ footprint.
670
411
 
671
- A production-ready pipeline that combines in-memory caching, disk caching via URLCache, downsampling, and request coalescing.
412
+ ## One loader for everything
672
413
 
673
- ### ImageLoader Actor
414
+ `GalleryLoader` stacks the layers: memory cache, `URLCache` on disk,
415
+ downsampling and request coalescing.
674
416
 
675
417
  ```swift
676
- import UIKit
677
- import ImageIO
678
-
679
- actor ImageLoader {
680
- static let shared = ImageLoader()
418
+ actor GalleryLoader {
419
+ static let shared = GalleryLoader()
681
420
 
682
- private let memoryCache = NSCache<NSString, UIImage>()
683
- private let session: URLSession
684
- private var inFlightTasks: [URL: Task<UIImage?, Never>] = [:]
685
-
686
- init() {
687
- let config = URLSessionConfiguration.default
688
- config.urlCache = URLCache(
689
- memoryCapacity: 50 * 1024 * 1024,
690
- diskCapacity: 200 * 1024 * 1024
691
- )
692
- config.requestCachePolicy = .returnCacheDataElseLoad
693
- config.httpMaximumConnectionsPerHost = 6
694
- session = URLSession(configuration: config)
695
-
696
- let totalMemory = ProcessInfo.processInfo.physicalMemory
697
- memoryCache.totalCostLimit = Int(totalMemory / 10)
698
- memoryCache.countLimit = 200
699
- }
700
-
701
- /// Load an image, optionally downsampling to the given display size.
702
- func load(from url: URL, displaySize: CGSize? = nil) async -> UIImage? {
703
- let cacheKey = cacheKey(url: url, size: displaySize)
704
-
705
- // 1. Check memory cache
706
- if let cached = memoryCache.object(forKey: cacheKey as NSString) {
707
- return cached
708
- }
709
-
710
- // 2. Coalesce duplicate in-flight requests
711
- if let existing = inFlightTasks[url] {
712
- return await existing.value
713
- }
714
-
715
- let task = Task<UIImage?, Never> {
716
- defer { inFlightTasks[url] = nil }
717
-
718
- guard let (data, response) = try? await session.data(from: url),
719
- let httpResponse = response as? HTTPURLResponse,
720
- httpResponse.statusCode == 200 else {
721
- return nil
722
- }
723
-
724
- // 3. Downsample if a display size is provided
725
- let image: UIImage?
421
+ private let memory: NSCache<NSString, UIImage> = {
422
+ let cache = NSCache<NSString, UIImage>()
423
+ cache.totalCostLimit = Int(ProcessInfo.processInfo.physicalMemory / 10)
424
+ cache.countLimit = 200
425
+ return cache
426
+ }()
427
+ private let network = ImageNetwork.session
428
+ private var running: [String: Task<UIImage?, Never>] = [:]
429
+
430
+ private func key(_ url: URL, _ size: CGSize?) -> String {
431
+ guard let size else { return url.absoluteString }
432
+ let wide = Int(size.width), tall = Int(size.height)
433
+ return "\(url.absoluteString)_\(wide)x\(tall)"
434
+ }
435
+
436
+ func load(_ url: URL, displaySize: CGSize? = nil, scale: CGFloat = 3) async -> UIImage? {
437
+ let slot = key(url, displaySize)
438
+ if let hit = memory.object(forKey: slot as NSString) { return hit }
439
+ if let shared = running[slot] { return await shared.value }
440
+
441
+ let session = network
442
+ let source = url
443
+ let job = Task { () -> UIImage? in
444
+ guard let (body, _) = try? await session.data(from: source) else { return nil }
726
445
  if let displaySize {
727
- image = Self.downsample(data: data, to: displaySize)
728
- } else {
729
- image = UIImage(data: data)
730
- }
731
-
732
- // 4. Store in memory cache
733
- if let image {
734
- let cost = Self.bitmapSize(of: image)
735
- memoryCache.setObject(image, forKey: cacheKey as NSString, cost: cost)
446
+ return Shrinker.fromData(body, maxPixels: max(displaySize.width, displaySize.height) * scale)
736
447
  }
737
-
738
- return image
448
+ return UIImage(data: body)
739
449
  }
450
+ running[slot] = job
451
+ defer { running[slot] = nil }
740
452
 
741
- inFlightTasks[url] = task
742
- return await task.value
453
+ guard let image = await job.value else { return nil }
454
+ let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
455
+ memory.setObject(image, forKey: slot as NSString, cost: cost)
456
+ return image
743
457
  }
744
458
 
745
- /// Remove a specific URL from the memory cache.
746
- func evict(url: URL, displaySize: CGSize? = nil) {
747
- let key = cacheKey(url: url, size: displaySize)
748
- memoryCache.removeObject(forKey: key as NSString)
459
+ func evict(url target: URL, displaySize box: CGSize? = nil) {
460
+ memory.removeObject(forKey: key(target, box) as NSString)
749
461
  }
750
462
 
751
- /// Purge all in-memory cached images.
752
463
  func purgeMemoryCache() {
753
- memoryCache.removeAllObjects()
754
- inFlightTasks.values.forEach { $0.cancel() }
755
- inFlightTasks.removeAll()
756
- }
757
-
758
- // MARK: - Private
759
-
760
- private func cacheKey(url: URL, size: CGSize?) -> String {
761
- if let size {
762
- return "\(url.absoluteString)_\(Int(size.width))x\(Int(size.height))"
763
- }
764
- return url.absoluteString
765
- }
766
-
767
- private static func downsample(data: Data, to pointSize: CGSize) -> UIImage? {
768
- let scale = UITraitCollection.current.displayScale
769
- let maxDimension = max(pointSize.width, pointSize.height) * scale
770
-
771
- let options: [CFString: Any] = [
772
- kCGImageSourceCreateThumbnailFromImageAlways: true,
773
- kCGImageSourceShouldCacheImmediately: true,
774
- kCGImageSourceCreateThumbnailWithTransform: true,
775
- kCGImageSourceThumbnailMaxPixelSize: maxDimension
776
- ]
777
-
778
- guard let source = CGImageSourceCreateWithData(data as CFData, nil),
779
- let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
780
- return nil
781
- }
782
-
783
- return UIImage(cgImage: cgImage)
784
- }
785
-
786
- private static func bitmapSize(of image: UIImage) -> Int {
787
- guard let cgImage = image.cgImage else { return 0 }
788
- return cgImage.bytesPerRow * cgImage.height
464
+ memory.removeAllObjects()
465
+ running.values.forEach { $0.cancel() }
466
+ running.removeAll()
789
467
  }
790
468
  }
791
469
  ```
792
470
 
793
- ### SwiftUI View Using the Pipeline
471
+ Order of lookup: memory, then an in-flight task for the same key, then a fetch
472
+ (downsampled when a display size is given, plain decode otherwise), then store
473
+ with the bitmap cost. The key carries the display size, so one URL can be
474
+ cached at several sizes.
794
475
 
795
476
  ```swift
796
- struct PipelineImageView: View {
477
+ struct GalleryTile: View {
797
478
  let url: URL
798
- var displaySize: CGSize = CGSize(width: 300, height: 300)
799
-
800
479
  @State private var image: UIImage?
801
- @State private var isLoading = true
480
+ @State private var failed = false
802
481
 
803
482
  var body: some View {
804
483
  ZStack {
805
484
  if let image {
806
- Image(uiImage: image)
807
- .resizable()
808
- .aspectRatio(contentMode: .fill)
809
- } else if isLoading {
810
- Color.secondary.opacity(0.15)
811
- .overlay(ProgressView())
485
+ Image(uiImage: image).resizable().scaledToFill()
486
+ } else if failed {
487
+ Image(systemName: "photo").foregroundStyle(.secondary)
812
488
  } else {
813
- Color.secondary.opacity(0.15)
814
- .overlay {
815
- Image(systemName: "photo")
816
- .font(.title2)
817
- .foregroundStyle(.secondary)
818
- }
489
+ ProgressView()
819
490
  }
820
491
  }
821
- .frame(width: displaySize.width, height: displaySize.height)
822
- .clipped()
823
492
  .task(id: url) {
824
- isLoading = true
825
- image = await ImageLoader.shared.load(from: url, displaySize: displaySize)
826
- isLoading = false
493
+ image = await GalleryLoader.shared.load(url, displaySize: CGSize(width: 120, height: 120))
494
+ failed = image == nil
827
495
  }
828
496
  }
829
497
  }
830
- ```
831
-
832
- ### Memory Warning Integration
833
498
 
834
- ```swift
835
- struct PhotoGridView: View {
836
- let photos: [Photo]
499
+ struct GalleryScreen: View {
500
+ let urls: [URL]
501
+ private let memoryWarning = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
837
502
 
838
503
  var body: some View {
839
504
  ScrollView {
840
- LazyVGrid(columns: [GridItem(.adaptive(minimum: 100), spacing: 2)], spacing: 2) {
841
- ForEach(photos) { photo in
842
- PipelineImageView(
843
- url: photo.thumbnailURL,
844
- displaySize: CGSize(width: 100, height: 100)
845
- )
846
- }
505
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
506
+ ForEach(urls, id: \.self) { GalleryTile(url: $0).frame(height: 120) }
847
507
  }
848
508
  }
849
- .onReceive(NotificationCenter.default.publisher(
850
- for: UIApplication.didReceiveMemoryWarningNotification
851
- )) { _ in
852
- Task {
853
- await ImageLoader.shared.purgeMemoryCache()
854
- }
509
+ .onReceive(memoryWarning) { _ in
510
+ Task { await GalleryLoader.shared.purgeMemoryCache() }
855
511
  }
856
512
  }
857
513
  }
858
514
  ```
859
515
 
860
- ### When to Use Each Layer
516
+ ## Which layer for which job
861
517
 
862
- | Scenario | Recommended Approach |
518
+ | Use | Layers |
863
519
  |---|---|
864
- | Simple profile avatar | AsyncImage |
865
- | Photo grid with scrolling | ImageLoader + downsampling + prefetch |
866
- | Offline-capable gallery | ImageLoader + URLCache disk caching |
867
- | Chat message images | ImageLoader + in-memory cache |
868
- | Full-resolution photo viewer | ImageLoader without downsampling |
869
- | Thumbnail in a widget | Downsample at fetch time, store in app group |
520
+ | A single profile avatar | `AsyncImage` |
521
+ | A scrolling photo grid | loader + downsampling + prefetching |
522
+ | A gallery that works offline | loader + `URLCache` on disk |
523
+ | Images inside chat messages | loader + memory cache |
524
+ | A full-resolution photo viewer | loader, no downsampling |
525
+ | A widget thumbnail | downsample when fetching, store in the app group |