@mmerterden/multi-agent-pipeline 20.6.0 → 20.8.0

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 (326) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +328 -329
  5. package/package.json +2 -2
  6. package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
  7. package/pipeline/scripts/_notices.mjs +12 -1
  8. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  9. package/pipeline/skills/.skill-manifest.json +106 -106
  10. package/pipeline/skills/shared/README.md +71 -71
  11. package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
  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/android-architecture/SKILL.md +2 -0
  16. package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
  17. package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
  18. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  19. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  20. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  21. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  22. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  23. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  24. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  25. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  26. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
  27. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  28. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
  29. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
  30. package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
  31. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  32. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  33. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  34. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
  35. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  36. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  37. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  38. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  39. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  40. package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
  41. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  42. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
  43. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  44. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  45. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  46. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  47. package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
  48. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  49. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
  50. package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
  51. package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  53. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  54. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  55. package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
  56. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  57. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  58. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  59. package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
  60. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
  61. package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
  62. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  63. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  64. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  65. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
  66. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  67. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  68. package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
  69. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  70. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  71. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  72. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  73. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  74. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  75. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  76. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  77. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  78. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  79. package/pipeline/skills/shared/external/council/SKILL.md +1 -0
  80. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  81. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  82. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  83. package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
  84. package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
  85. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  86. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  87. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  88. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  89. package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
  90. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  91. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  92. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  93. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  94. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  95. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  96. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  97. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  98. package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
  99. package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
  100. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
  101. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  102. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  103. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  104. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  105. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  106. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  107. package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
  108. package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
  109. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  110. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  111. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  112. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  113. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  114. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
  115. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  116. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  117. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  118. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  119. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
  120. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  121. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  122. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  123. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  124. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  125. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  126. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  127. package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
  128. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  129. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  130. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  131. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  132. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  133. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  134. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
  135. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
  136. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
  137. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
  138. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
  139. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
  140. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
  141. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
  142. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
  143. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
  144. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
  145. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
  146. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
  147. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
  148. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
  149. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
  150. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
  151. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  152. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  153. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  154. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  155. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  156. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  157. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  158. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  159. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  160. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  161. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  162. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  163. package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
  164. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  165. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  166. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  167. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  168. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  169. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  170. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  171. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  172. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  173. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  174. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  175. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  176. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  177. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  178. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  179. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  180. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  181. package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
  182. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  183. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  184. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  185. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  186. package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
  187. package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
  188. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  189. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  190. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  191. package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
  192. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
  193. package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
  194. package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
  195. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  196. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  197. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  198. package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
  199. package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
  200. package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
  201. package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
  202. package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
  203. package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
  204. package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
  205. package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
  206. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  207. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  208. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  209. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  210. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  211. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  212. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
  213. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  214. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  215. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  216. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  217. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  218. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  219. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  220. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  221. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  222. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  223. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  224. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  225. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  226. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  227. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
  228. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  229. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  230. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  231. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  232. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  233. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  234. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  235. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  236. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  237. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  238. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  239. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  240. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  241. package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
  242. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  243. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  244. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
  245. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  246. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  247. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  248. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  249. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  250. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  251. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
  252. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  253. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  254. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  255. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
  256. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  257. package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
  258. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  259. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
  260. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  261. package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
  262. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  263. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  264. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  265. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  266. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  267. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  268. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  269. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  270. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  271. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  272. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  273. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  274. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  275. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  276. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  277. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  278. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  279. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  280. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  281. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  282. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  283. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  284. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  285. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  286. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
  287. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  288. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
  289. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  290. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
  291. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  292. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  293. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  294. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  295. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  296. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  297. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  298. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  299. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  300. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  301. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  302. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  303. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  304. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  305. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  306. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  307. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  308. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  309. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  310. package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
  311. package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
  312. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  313. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  314. package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
  315. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  316. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  317. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  318. package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
  319. package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
  320. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  321. package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
  322. package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
  323. package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
  324. package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
  325. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
  326. 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 |