<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Emre Degirmenci]]></title><description><![CDATA[iOS Engineer with 8+ years of experience. Writing about building apps with Swift, SwiftUI, and UIKit for Apple platforms, mainly iOS.]]></description><link>https://emredegirmenci.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!ozjy!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40cb062b-0d35-4584-a19e-6dc2d73314ef_3546x3546.jpeg</url><title>Emre Degirmenci</title><link>https://emredegirmenci.substack.com</link></image><generator>Substack</generator><lastBuildDate>Fri, 04 Sep 2026 18:00:05 GMT</lastBuildDate><atom:link href="https://emredegirmenci.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Emre Degirmenci]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[emredegirmenci@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[emredegirmenci@substack.com]]></itunes:email><itunes:name><![CDATA[Emre Degirmenci]]></itunes:name></itunes:owner><itunes:author><![CDATA[Emre Degirmenci]]></itunes:author><googleplay:owner><![CDATA[emredegirmenci@substack.com]]></googleplay:owner><googleplay:email><![CDATA[emredegirmenci@substack.com]]></googleplay:email><googleplay:author><![CDATA[Emre Degirmenci]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Teaching Siri to talk about your favorite walks in Walk Mate]]></title><description><![CDATA[iOS 27 App Intents Journey]]></description><link>https://emredegirmenci.substack.com/p/ios-27-app-intents-journey</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/ios-27-app-intents-journey</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Tue, 01 Sep 2026 18:58:18 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!W892!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">In the last week&#8217;s post</a>, <a href="https://apple.co/4mz7vev">Walk Mate</a> started donating favorite routes to Spotlight. Favorite a route and give a title. Search in Spotlight, tap, the route opens on the map. That used App Intents the not a fancy way: and <em><strong>IndexedEntity </strong></em>for the favorite, an <em><strong>OpenIntent </strong></em>that load it and a small router so a Spotlight tap can reach the same reducer as a tap inside the app. There was no Siri phrases yet. </p><p>Spotlight already knows the walk exists. Siri on iOS 27 is supposed to be able to talk about it now.</p><p>Last time, Spotlight could find a favorite route. This time, I&#8217;m making Siri able to talk about it, hand it to Maps and open it by voice. </p><h4>1. Make the entity speak: @Property and Spotlight keywords</h4><p>Before Siri can refer to a favorite, the entity behind it needs a field Siri is allowed to read aloud. I updated <em><strong>WalkMatePlaceEntity </strong></em>so <em><strong>name </strong></em>is wrapped in <em><strong>@Property. </strong></em>That wrapper publishes a field so Shortcuts can display it and Siri can say it.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;0c0c5d4b-09c8-494b-9227-918991695ae1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct WalkMatePlaceEntity: IndexedEntity {
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Favorite walk"
    static let defaultQuery = WalkMatePlaceEntityQuery()
    static let spotlightIndexName = "WalkMateFavorites"

    let id: UUID
    @Property(title: "Name")
    var name: String?
    var startLatitude: Double
    var startLongitude: Double
}</code></pre></div><p>I also store the route&#8217;s starting coordinate directly on the entity that I&#8217;ll need it later for the Maps handoff. Note the entity only exports the trailhead not the whole walk. </p><p>Spotlight search still works the way <a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Part 1</a> showed -searching &#8220;Berlin&#8221; finds a favorite named Berlin- because <em><strong>attributeSet</strong></em> puts the name into Spotlight keywords as well as the title:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;046abb8e-3309-4f69-93ef-219ffc8bb5d6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">var attributeSet: CSSearchableItemAttributeSet {
    let attributes = defaultAttributeSet
    attributes.displayName = name
    attributes.title = name
    if let name {
        attributes.keywords = [name]
    }
    return attributes
}</code></pre></div><h4>2. Let Shortcuts search and reindex</h4><p><em><strong>EnumerableEntityQuery</strong></em> is unchanged. Spotlight and Shortcuts still resolve a favorite by id through <em><strong>entities(for:)</strong></em> and list everything through <em><strong>allEntities()</strong></em>.</p><p>Two things are new on top of that:</p><p>1-) When a Shortcuts parameter is &#8220;pick a saved walk,&#8221; <em><strong>suggestedEntities() </strong></em>offers the full list and <em><strong>EntityStringQuery </strong></em>filters it as you type:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;4fc7a747-539b-402a-a05e-dd58285cfeba&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">func suggestedEntities() async throws -&gt; [WalkMatePlaceEntity] {
    try await allEntities()
}

extension WalkMatePlaceEntityQuery: EntityStringQuery {
    func entities(matching string: String) async throws -&gt; [WalkMatePlaceEntity] {
        try await allEntities().filter { entity in
            entity.name?.localizedStandardContains(string) == true
        }
    }
}</code></pre></div><p>2-) iOS 27 can ask the app to rebuild Spotlight. <em><strong>IndexedEntity</strong></em> describes the shape of what goes in the index but it doesn&#8217;t reindex by itself. <a href="https://developer.apple.com/documentation/appintents/making-app-entities-available-in-spotlight">IndexedEntityQuery</a> is the write Spotlight calls when the index is stale:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;f306bdea-b03e-4d80-adad-d9661733d109&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@available(iOS 27, *)
extension WalkMatePlaceEntityQuery: IndexedEntityQuery {
    func reindexEntities(
        for identifiers: [WalkMatePlaceEntity.ID],
        indexDescription: CSSearchableIndexDescription
    ) async throws {
        let entities = FavoriteRouteStorage.load()
            .filter { identifiers.contains($0.id) }
            .compactMap(WalkMatePlaceEntity.init)
        try await CSSearchableIndex.default().indexAppEntities(entities)
    }

    func reindexAllEntities(
        indexDescription: CSSearchableIndexDescription
    ) async throws {
        let entities = FavoriteRouteStorage.load().compactMap(WalkMatePlaceEntity.init)
        try await CSSearchableIndex.default().indexAppEntities(entities)
    }
}</code></pre></div><p>Same <em><strong>FavoriteRouteStorage.load()</strong></em>. The system passes a <em><strong>CSSearchableIndexDescription</strong></em>; I write the same default index <a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Part 1</a> used.</p><h4>3. Keep Siri&#8217;s phrase list in sync:</h4><p><a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Part 1&#8217;s </a><em><strong><a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">donateAll()</a></strong></em> deleted every <em><strong>WalkMatePlaceEntity</strong></em> in Spotlight then indexed whatever SQLite still had. That write is unchanged. What&#8217;s new: it also tells App Shortcuts the favorite <em><strong>names </strong></em>changed, so a phrase like &#8220;Load Berlin in Walk Mate&#8221; stays valid after a rename, add or delete.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;6efc1c35-c065-4cf0-9f5f-affaa126ec5a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">static func donateAll() {
    Task { @MainActor in
        WalkMateAppShortcuts.updateAppShortcutParameters()
    }
    Task {
        guard CSSearchableIndex.isIndexingAvailable() else { return }
        let entities = FavoriteRouteStorage.load().compactMap(WalkMatePlaceEntity.init)
        let namedIndex = CSSearchableIndex(name: spotlightIndexName)
        try? await namedIndex.deleteAppEntities(ofType: WalkMatePlaceEntity.self)
        let index = CSSearchableIndex.default()
        try? await index.deleteAppEntities(ofType: WalkMatePlaceEntity.self)
        guard !entities.isEmpty else { return }
        try? await index.indexAppEntities(entities)
    }
}</code></pre></div><p>The <em><strong>App.init</strong></em> function also calls <em><strong>updateAppShortcutParameters()</strong></em> once at launch, so the phrase cache is already warm the first time Siri needs it.</p><h4>4. Open a favorite by voice</h4><p><a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Part 1</a> had a single <em><strong>OpenWalkMatePlaceIntent: OpenIntent. </strong></em>That is still the iOS 27 open but it now adopts Apple&#8217;s <a href="https://developer.apple.com/documentation/appintents/appschema/systemintent/open">.</a><em><strong><a href="https://developer.apple.com/documentation/appintents/appschema/systemintent/open">system.open</a> </strong></em>schema Siri AI treats &#8220;open this favorite&#8221; as the system open action. <em><strong><a href="https://developer.apple.com/documentation/appintents/targetcontentprovidingintent">TargetContentProvidingIntent</a> </strong></em>is the iOS piece that actually brings the scene to that entity.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;f886585f-9358-4a42-8692-757a9fecc344&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@available(iOS 27, *)
@AppIntent(schema: .system.open)
struct OpenWalkMatePlaceIntent: OpenIntent, TargetContentProvidingIntent {
    var target: WalkMatePlaceEntity

    @MainActor
    func perform() async throws -&gt; some IntentResult {
        QuickActionRouter.dispatch(.openFavorite(id: target.id))
        return .result()
    }
}</code></pre></div><p><em><strong>ShowSavedWalkIntent</strong></em> is the custom intent that runs on iOS 18+: same dispatch, same reducer.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;77907ee5-d60f-49d5-844d-2063d7c0631b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct ShowSavedWalkIntent: AppIntent {
    static let title: LocalizedStringResource = &#8220;Show Saved Walk&#8221;
    static let openAppWhenRun = true

    @Parameter(title: &#8220;Saved walk&#8221;)
    var walk: WalkMatePlaceEntity

    @MainActor
    func perform() async throws -&gt; some IntentResult {
        QuickActionRouter.dispatch(.openFavorite(id: walk.id))
        return .result()
    }
}</code></pre></div><p>Both send <em><strong>.openFavorite(id:)</strong></em>. Part 1 already queued that id during splash. Tap <strong>&#8220;Berlin&#8221;</strong> in Spotlight, Shortcuts, Siri and you still get the same map as tap <strong>&#8220;Berlin&#8221;</strong> in Favorite Routes.</p><h4>5. Give Siri a phrase to say</h4><p>Writing an <em><strong>AppIntent</strong></em> is not enough for Siri to pick it up as a command. You register it on an <em><strong>AppShortcutsProvider</strong></em>. Every phrase has to interpolate <em><strong>\(.applicationName)</strong></em>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;7b39c306-f67f-437b-8d0a-0ec27959d43d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct WalkMateAppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: ShowSavedWalkIntent(),
            phrases: [
                &#8220;Load \(\.$walk) in \(.applicationName)&#8221;
            ],
            shortTitle: &#8220;Open Favorite Walk&#8221;,
            systemImageName: &#8220;figure.walk&#8221;
        )
    }
}</code></pre></div><p><em><strong>\(\.$walk)</strong></em> expands with the donated favorite names. That is why <em><strong>donateAll()</strong></em> and <em><strong>App.init</strong></em> call <em><strong>updateAppShortcutParameters()</strong></em>. After you name a walk <strong>&#8220;Berlin&#8221;</strong>, <strong>&#8220;Load Berlin in Walk Mate&#8221;</strong> is a real phrase.</p><p>(<a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Part 1</a> already showed &#8220;Open Favorite Walk&#8221; and &#8220;Find Favorite walk&#8221; inside the Shortcuts app because the system lists every discoverable intent. The tile and the spoken phrase are new. They come from this provider.)</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!W892!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!W892!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 424w, https://substackcdn.com/image/fetch/$s_!W892!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 848w, https://substackcdn.com/image/fetch/$s_!W892!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!W892!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!W892!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg" width="1206" height="1306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1306,&quot;width&quot;:1206,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;IMG_5284.jpg&quot;,&quot;title&quot;:&quot;IMG_5284.jpg&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="IMG_5284.jpg" title="IMG_5284.jpg" srcset="https://substackcdn.com/image/fetch/$s_!W892!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 424w, https://substackcdn.com/image/fetch/$s_!W892!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 848w, https://substackcdn.com/image/fetch/$s_!W892!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!W892!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F648a1465-aea4-4292-b70f-d8ccefcdc976_1206x1306.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h4>6. Understand &#8220;this walk&#8220; on screen</h4><p>Siri can mean the row you are looking at if the view is annotated. Each <em><strong>FavoriteRouteCard</strong></em> gets the entity id. iOS 18.4+ <em><strong>appEntityIdentifier</strong></em>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;d4aae604-731b-4499-bc7f-6f2e029ad754&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">func walkMateEntityIdentifier(_ id: UUID) -&gt; some View {
    if #available(iOS 18.4, *) {
        appEntityIdentifier(
            EntityIdentifier(for: WalkMatePlaceEntity.self, identifier: id)
        )
    } else {
        self
    }
}</code></pre></div><p>This is separate from exporting data. It just lets someone say &#8220;third walk in the favorites list&#8221; while the favorites list is open and have Siri know which one that means.</p><h4>7. Hand the walk&#8217;s starting point to Apple Maps</h4><p>A favorite is a private loop. Apple&#8217;s Maps place schema (<em><strong>@AppEntity(schema: .maps.place)</strong></em>) is a POI which contains hours, category etc. I did not adopt <em><strong>.maps.place </strong></em>since it doesn&#8217;t fit what <a href="https://apple.co/4mz7vev">Walk Mate</a> provides.</p><p>What I exported instead is a <em><strong><a href="https://developer.apple.com/documentation/geotoolbox/placedescriptor">PlaceDescriptor</a> </strong></em>for the first coordinate and the name. Shortcuts or Siri can hand that to Maps so you can get to where the walk starts. Maps cannot ingest <a href="https://apple.co/4mz7vev">Walk Mate</a>&#8217;s polyline. Extra Maps waypoints would be a different path Maps drew.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;e8324330-71a3-4a55-8be3-2251c831fdda&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@available(iOS 26.4, *)
extension WalkMatePlaceEntity: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        ValueRepresentation(exporting: \.startPlace)
    }
}

@available(iOS 26, *)
extension WalkMatePlaceEntity {
    var startPlace: PlaceDescriptor {
        PlaceDescriptor(
            representations: [
                .coordinate(
                    CLLocationCoordinate2D(
                        latitude: startLatitude,
                        longitude: startLongitude
                    )
                )
            ],
            commonName: nil
        )
    }
}</code></pre></div><blockquote><p>The <em><strong>@available(iOS 26.4, *) </strong></em>is not about <em><strong>Transferable </strong></em>itself and it is not about iOS 27. <em><strong>ValueRepresentation(IntentValueRepresentation) </strong></em>is the API that exports an <em><strong>AppEntity </strong></em>as a structured system value (<em><strong>PlaceDescriptor</strong></em>) instead of a file. File/data representations cannot carry a coordinate, Maps needs a place value. Apple added that structured export in 26.4.</p></blockquote><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!hcJ-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!hcJ-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 424w, https://substackcdn.com/image/fetch/$s_!hcJ-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 848w, https://substackcdn.com/image/fetch/$s_!hcJ-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 1272w, https://substackcdn.com/image/fetch/$s_!hcJ-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!hcJ-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png" width="1456" height="1043" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1043,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1282514,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/213210914?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!hcJ-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 424w, https://substackcdn.com/image/fetch/$s_!hcJ-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 848w, https://substackcdn.com/image/fetch/$s_!hcJ-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 1272w, https://substackcdn.com/image/fetch/$s_!hcJ-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb52d375f-fbd7-489c-90c8-c7e29fed7602_1536x1100.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h4>8. Keep every device in sync: <em>SyncableEntity</em></h4><p>Favorites in Walk Mate<em> </em>already sync across devices via CloudKit, using the same <em><strong>UUID </strong></em>(<em><strong>favorite.id</strong></em>) everywhere. iOS27&#8217;s <em><strong>SyncableEntity </strong></em>protocol is what tells Siri that fact that this id is stable across a user&#8217;s devices, so a conversation about a favorite can move from iPhone to iPad.<br><a href="https://developer.apple.com/documentation/AppIntents/SyncableEntity">Apple SyncableEntity says;</a></p><blockquote><p>The presence of this protocol tells the system that it can refer to your entity consistently across devices. For example, Siri uses this capability to transfer a conversation from one device to another.</p></blockquote><p>Since <em><strong>WalkMatePlaceEntity.id </strong></em>was already the CloudKit-synced UUID, adopting the protocol is a one-line addition:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;480e623a-a6e7-4952-b763-6832fc979260&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@available(iOS 27, *)
extension WalkMatePlaceEntity: SyncableEntity {}</code></pre></div><h4>9. Donate the action, not only the entity</h4><p><a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Part 1</a> donated entities to Spotlight. That teaches the system the walk exists. It doesn&#8217;t teach the system you open it.</p><p>When a favorite is opened from the Favorites UI, I donate that open after it succeeds. On iOS 27 the donated intent is the schema open.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;c91b78ac-27fc-4a64-98ca-b981f2bead2a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">donateFavoriteOpened: { favorite in
    guard let entity = WalkMatePlaceEntity(favorite) else { return }
    if #available(iOS 27, *) {
        var intent = OpenWalkMatePlaceIntent()
        intent.target = entity
        IntentDonationManager.shared.donate(intent: intent)
    } else {
        var intent = ShowSavedWalkIntent()
        intent.walk = entity
        IntentDonationManager.shared.donate(intent: intent)
    }
}</code></pre></div><p>Donate after the action completes. Do not donate on every list render or you&#8217;ll flood the system with noise.</p><div><hr></div><h4>Phrases (Siri Voice Commands (Saved Walks &amp; Search))</h4><p>&#128483;&#65039; <em>&#8220;Generate a route in Walk Mate&#8221;, &#8220;Generate me a route in Walk Mate&#8221;, &#8220;Make me a walk in Walk Mate&#8221;, &#8220;Create a walk in Walk Mate&#8221;, &#8220;Generate walking routes in Walk Mate&#8221;</em></p><p>When the app closed and said these, it opens the app and immediately generates a new route based on the distance.</p><p>&#128483;&#65039; <em>&#8220;Load Riverside Loop in Walk Mate&#8221;</em></p><p>Siri opens Walk Mate directly to <strong><span>Riverside Loop</span></strong> on the map.</p><p>&#128483;&#65039; &#8220;<em>Open Riverside Loop in Walk Mate&#8221;</em> or <em>&#8220;Open Riverside Loop with Walk Mate&#8221;</em></p><p>Siri resolves the entity and opens <strong><span>Riverside Loop</span></strong> on the map.</p><p>&#128483;&#65039; <em>&#8220;Search Riverside Loop in Walk Mate&#8221;</em></p><p>App opens in the foreground, searches favorites and displays <strong><span>Riverside Loop</span></strong> on the map.</p><p>&#128483;&#65039; <em>&#8220;Search Central Park in Walk Mate&#8221;</em> (not a favorited route)</p><p>App opens gracefully to the <strong><span>Favorites List</span></strong> so I can pick or browse saved routes.</p><h4>Shortcuts App</h4><p><strong><span>Generate Routes (Custom Distance)</span></strong></p><p>1. Open the <strong><span>Shortcuts</span></strong> app.<br>2. Tap <strong><span>+</span></strong> (New Shortcut) &#8594; Add Action.<br>3. Search for <em><strong>Walk Mate</strong></em> &#8594; Select <strong><span>Generate Routes</span></strong>.<br>4. Set <strong><span>Distance</span></strong> to <code>5 km</code>.<br>5. Tap <strong><span>Run</span></strong>.</p><p>Walk Mate opens to the foreground and starts generating a route clamped to ~5 km. Also, Walk Mate opens and generates routes using the app's current default target distance without specifying the Distance from Shortcuts app. <span>There is also a caveat for upper-lower bounds for distances like when the user enters a distance value more than 20 kms or lesser 3 kms that distance value safely clamps within the 3-20 km operating range.</span></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!O76h!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!O76h!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 424w, https://substackcdn.com/image/fetch/$s_!O76h!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 848w, https://substackcdn.com/image/fetch/$s_!O76h!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 1272w, https://substackcdn.com/image/fetch/$s_!O76h!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!O76h!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png" width="1456" height="781" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:781,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:958130,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/213210914?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!O76h!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 424w, https://substackcdn.com/image/fetch/$s_!O76h!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 848w, https://substackcdn.com/image/fetch/$s_!O76h!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 1272w, https://substackcdn.com/image/fetch/$s_!O76h!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40c7664f-ea64-489f-ba6e-e92ba8bdda51_1768x948.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>When the <em><strong>Open Favorite Route </strong></em>shortcut runs, it asks you to select with which favorite you want to open the app;</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!PzAJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!PzAJ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 424w, https://substackcdn.com/image/fetch/$s_!PzAJ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 848w, https://substackcdn.com/image/fetch/$s_!PzAJ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 1272w, https://substackcdn.com/image/fetch/$s_!PzAJ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!PzAJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png" width="722" height="774" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:774,&quot;width&quot;:722,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:298340,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/213210914?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!PzAJ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 424w, https://substackcdn.com/image/fetch/$s_!PzAJ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 848w, https://substackcdn.com/image/fetch/$s_!PzAJ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 1272w, https://substackcdn.com/image/fetch/$s_!PzAJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffd12e462-c7ce-41e6-b426-994b2e88132d_722x774.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>If you have multiple walks with similar names (e.g. &#8220;Park North&#8221; and &#8220;Park South&#8221;) and you say: <em><strong>&#8220;Open Park in Walk Mate&#8221;</strong></em>, Siri can&#8217;t uniquely match one, so it prompts <em><strong>&#8220;Which one?&#8221; </strong></em>with the list of matching walks.</p><h3>What I did not add</h3><p>Even though <a href="https://apple.co/4mz7vev">Walk Mate</a> has no search bar or a results screen for it, <br><em><strong>.system.searchInApp </strong></em>stays and Siri hands Walk Mate a search term. Unique match loads that favorite otherwise the existing Favorites list opens. Since <a href="https://apple.co/4mz7vev">Walk Mate</a> has no start/stop button to start your walking sessions there is no start button for Siri to press. You walk, medals come to you. There is also no option for sending the full loop to Apple Maps.</p><div><hr></div><h3><strong>Sources:</strong></h3><ul><li><p><a href="https://emredegirmenci.substack.com/p/walk-mates-first-app-intents">Walk Mate&#8217;s first App Intents</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents">Get started with App Intents</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/making-app-entities-available-in-spotlight">Making app entities available in Spotlight</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/apple-intelligence-and-siri-ai">Apple Intelligence and Siri AI</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/making-actions-and-content-discoverable-by-apple-intelligence">Making actions and content discoverable by Apple Intelligence</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/appshortcutsprovider">AppShortcutsProvider</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/donating-your-apps-data-and-actions-to-the-system">Donating your app&#8217;s data and actions to the system</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/providing-contextual-cues-to-apple-intelligence-and-siri">Providing contextual cues to Apple Intelligence and Siri</a></p></li></ul>]]></content:encoded></item><item><title><![CDATA[Walk Mate’s first App Intents]]></title><description><![CDATA[search a favorite route, tap, see that route]]></description><link>https://emredegirmenci.substack.com/p/walk-mates-first-app-intents</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/walk-mates-first-app-intents</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sun, 23 Aug 2026 16:59:07 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!GreE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I started to integrate App Intents into <a href="https://apple.co/4mz7vev">Walk Mate</a> with the very first step of exposing favorited walks to Spotlight/Shortcuts. This is the first part: one entity, an index, a query, and a tap that loads that favorite on the map.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!GreE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!GreE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 424w, https://substackcdn.com/image/fetch/$s_!GreE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 848w, https://substackcdn.com/image/fetch/$s_!GreE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 1272w, https://substackcdn.com/image/fetch/$s_!GreE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!GreE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png" width="1456" height="814" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:814,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:882634,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/212295011?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!GreE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 424w, https://substackcdn.com/image/fetch/$s_!GreE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 848w, https://substackcdn.com/image/fetch/$s_!GreE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 1272w, https://substackcdn.com/image/fetch/$s_!GreE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc46c5823-4f45-429f-a436-7dcd9c5cd953_2048x1145.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Get started with App Intents - Apple Developer</figcaption></figure></div><p><span>This post is that first step only: one entity, Spotlight, and the </span><em><strong>OpenIntent</strong></em><span> Spotlight needs. No map navigation intents. That same open intent is also visible in the Shortcuts app, so a favorite can be opened from there without more types.</span></p><h4>Let&#8217;s jump into the code:<br></h4><p>To enable matching by meaning and not just text, conform to <em><strong>IndexedEntity</strong></em> protocol.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;d0c196a9-f1a7-4bd8-8a09-d01b949bbe91&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct WalkMatePlaceEntity: IndexedEntity {
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Favorite walk"
    static let defaultQuery = WalkMatePlaceEntityQuery()

    let id: UUID
    var name: String?

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "\(name ?? L10n.Spotlight.unnamedFavorite)",
            image: .init(systemName: "figure.walk")
        )
    }
}</code></pre></div><p>Conforming to <em><strong>IndexedEntity</strong></em> allows the app to donate entities using the Spotlight index to get the benefits of semantic understanding. When an entity is donated, Siri can resolve it by name, by property, or by context, without requiring a custom property query.</p><p>To keep things moving, I added ways to convert between the data model and the entity. <br>The WalkMatePlaceEntity&#8217;s initializer maps from FavoriteRoute to pull out what the entity needs. </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;4c5411ea-d378-4b8f-a81d-988bcaba5cbc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">init?(_ favorite: FavoriteRoute) {
    guard !favorite.route.coordinates.isEmpty else { return nil }
    let title = favorite.name.isEmpty
        ? AppSettingsManager.formatDistance(favorite.route.distance)
        : favorite.name
    id = favorite.id
    name = title
}</code></pre></div><p>And the query and Spotlight donate call <em><strong>WalkMatePlaceEntity.init </strong></em>/ <em><strong>compactMap(WalkMatePlaceEntity.init)</strong></em>.</p><h4>The Query</h4><p>When Spotlight asks for a favorite place, the query reads the same favorites the app already persists. <em><strong>FavoriteRouteStorage.load() </strong></em>is that read. No new db.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;19f0ef6d-ac40-4a7c-92bd-1a71feca5b4d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct WalkMatePlaceEntityQuery: EnumerableEntityQuery {
    func entities(for identifiers: [WalkMatePlaceEntity.ID]) async throws -&gt; [WalkMatePlaceEntity] {
        FavoriteRouteStorage.load()
            .filter { identifiers.contains($0.id) }
            .compactMap(WalkMatePlaceEntity.init)
    }

    func allEntities() async throws -&gt; [WalkMatePlaceEntity] {
        FavoriteRouteStorage.load().compactMap(WalkMatePlaceEntity.init)
    }
}</code></pre></div><p>Spotlight hits the query. The query calls <em><strong>FavoriteRouteStorage.load()</strong></em>, the same read the favorites UI uses, then maps rows to <em><strong>WalkMatePlaceEntity. </strong></em>That is the shared resource: existing SQLite favorites, not a second store. <em><strong>entities(for identifiers:)</strong></em> fetches favorites by ID and <em><strong>allEntities()</strong></em> method that returns all favorites.</p><h4>Donating to the index</h4><p>There&#8217;s one more piece which can be easily forgettable. <em><strong>IndexedEntity</strong></em> is the shape that Spotlight and Siri are allowed to index this type. It does not write the index.<br><br><em><strong>donateAll() </strong></em> is the write. <em><strong>saveFavorites</strong></em> replaces every favorite, so I delete all <em><strong>WalkMatePlaceEntity </strong></em>rows in Spotlight, then <em><strong>indexAppEntities </strong></em>with whatever <em><strong>FavoriteRouteStorage.load() </strong></em>still has. Rename, add, or delete a favorite and the index matches SQLite again.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;794ffe7d-f088-448c-8860-61a66c65a544&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">static func donateAll() {
    Task {
        let entities = FavoriteRouteStorage.load().compactMap(WalkMatePlaceEntity.init)
        let index = CSSearchableIndex.default()
        try? await index.deleteAppEntities(ofType: WalkMatePlaceEntity.self)
        guard !entities.isEmpty else { return }
        try? await index.indexAppEntities(entities)
    }
}</code></pre></div><p>And it called in <em><strong>SQLitePersistenceStore </strong></em>after a successful favorite route saving process:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;ba221546-ecdb-418f-b6a6-5c370e182a9a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">func saveFavorites(_ routes: [FavoriteRoute]) {
    WalkMatePlaceEntity.donateAll()
}</code></pre></div><p>I favorited a route and named it &#8220;Berlin&#8221; and when I search &#8220;Berlin&#8221; through iOS spotlight I can see the name and figure.walk system image added in <em><strong>DisplayRepresentation</strong></em> show up.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ShEh!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ShEh!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 424w, https://substackcdn.com/image/fetch/$s_!ShEh!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 848w, https://substackcdn.com/image/fetch/$s_!ShEh!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 1272w, https://substackcdn.com/image/fetch/$s_!ShEh!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ShEh!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png" width="632" height="440.5769230769231" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1015,&quot;width&quot;:1456,&quot;resizeWidth&quot;:632,&quot;bytes&quot;:1570688,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/212295011?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ShEh!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 424w, https://substackcdn.com/image/fetch/$s_!ShEh!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 848w, https://substackcdn.com/image/fetch/$s_!ShEh!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 1272w, https://substackcdn.com/image/fetch/$s_!ShEh!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8b5d2637-f8fd-4901-98a8-fd7e81827d08_1500x1046.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Favorite walk appearance in the Spotlight search</figcaption></figure></div><h4>Opening the favorite walk from Spotlight and Shortcuts app</h4><p>Apple&#8217;s Spotlight article says the next type to add is an <strong><a href="https://developer.apple.com/documentation/appintents/openintent">OpenIntent</a>. </strong>The <em><strong>target </strong></em>is the entity Spotlight already has. When someone taps a donated result, the system is supposed to fill <em><strong>target </strong></em>and run <em><strong> perform().</strong></em></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;c264f832-ca30-4b31-a49b-85aeb2d52627&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct OpenWalkMatePlaceIntent: OpenIntent {
    static let title: LocalizedStringResource = "Open Favorite Walk"
    static let description = IntentDescription("Opens a saved walking route.")

    @Parameter(title: "Saved walk")
    var target: WalkMatePlaceEntity

    @MainActor
    func perform() async throws -&gt; some IntentResult {
        AppIntentRouter.dispatch(.openFavorite(id: target.id))
        return .result()
    }
}</code></pre></div><p><em><strong>OpenIntent </strong></em>is a system intent. The system brings the app forward. <em><strong>perform() </strong></em>is where the app has to change the UI. Walk Mate&#8217;s UI lives in a TCA <em><strong>Store, </strong></em>not inside the intent, so the intent cannot call <em><strong>FavoriteRoutesView </strong></em>directly.</p><p><em><strong>AppIntentRouter </strong></em>is that bridge. <em><strong>App.init </strong></em>creates the root store, then hands the router a <em><strong>send </strong></em>closure. <em><strong>perform() </strong></em>dispatches <em><strong>.openFavorite(id:).</strong></em> If the intent runs before the store exists, the action waits and flushes when <em><strong>send </strong></em>is set.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;7b555698-4761-4664-802b-c8913d0d04b8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@MainActor
enum AppIntentRouter {
    static func dispatch(_ action: ContentFeature.Action) {
        if let send {
            send(action)
        } else {
            pending = action
        }
    }
}</code></pre></div><p><em><strong>.openFavorite </strong></em>is not a new route loader. It looks up the <em><strong>FavoriteRoute </strong></em>by the Spotlight UUID and sends the same <em><strong>favorites(.load(favorite)) </strong></em>the Favorites list already uses. Tap &#8220;Berlin&#8221; in Spotlight/Shortcuts app and you get the same map states as tap &#8220;Berlin&#8221; in Favorite Routes.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;dad0f75f-a751-469a-ac38-3899e56ea8e8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">case let .openFavorite(id):
    state.pendingSpotlightFavoriteID = id
    return applyPendingSpotlightFavorite(to: &amp;state, clearPending: true)</code></pre></div><p>If the tap lands during splash, the id is queued. Apply it after splash, not under the splash view.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!YfCG!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!YfCG!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 424w, https://substackcdn.com/image/fetch/$s_!YfCG!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 848w, https://substackcdn.com/image/fetch/$s_!YfCG!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 1272w, https://substackcdn.com/image/fetch/$s_!YfCG!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!YfCG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png" width="1456" height="777" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:777,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:5558235,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/212295011?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!YfCG!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 424w, https://substackcdn.com/image/fetch/$s_!YfCG!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 848w, https://substackcdn.com/image/fetch/$s_!YfCG!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 1272w, https://substackcdn.com/image/fetch/$s_!YfCG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57881054-9ecc-42a1-81f1-c6743dbe3d36_3870x2065.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Favorite walk appearance in the Apple Shortcuts app</figcaption></figure></div><h4>The tap that is not perform()</h4><p><em><strong>indexAppEntities </strong></em>writes a Core Spotlight item whose unique identifier is the entity id. Tap that row and Handoff continues a user activity of type <em><strong>CSSearchableItemActionType. </strong></em>The favorite UUID is in <em><strong>userInfo </strong></em>under <em><strong>CSSearchableItemActivityIdentifier. </strong></em>See <a href="https://developer.apple.com/documentation/corespotlight/cssearchableitemactiontype">CSSearchableItemActionType</a>.<em><strong> </strong></em></p><p>Walk Mate also uses a custom <em><strong>(UIWindowSceneDelegate) </strong></em>for home-screen quick actions. That delegate never implemented <em><strong>scene(_:continue). </strong></em>The activity arrived. Nobody read the UUID. The app came to the foreground on the home map.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;a74ff951-ca24-48e8-a556-25e3c3634768&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">.onContinueUserActivity(CSSearchableItemActionType) { activity in
    if let id = SpotlightFavoriteActivity.favoriteID(from: activity) {
        store.send(.openFavorite(id: id))
    }
}</code></pre></div><p>The same parse runs from <em><strong>application(_:continue) </strong></em>and from <em><strong>scene(_:continue). </strong></em>Cold launch puts the activity on <em><strong>UIScene.ConnectionOptions.userActivities </strong></em>before <em><strong>RootView</strong></em> exists, so that set is read in <em><strong>application(_:n:options).</strong></em></p><p><em><strong>SpotlightFavoriteActivity </strong></em>pulls a <em><strong>UUID </strong></em>out of the activity: the Spotlight identifier, <em><strong>targetContentIdentifier, </strong></em>or the last path component if the system prefixes the entity type. Both doors send <em><strong>.openFavorite(id:). </strong></em>One reducer. One map.</p><div><hr></div><h3><strong>Sources:</strong></h3><ul><li><p><a href="https://developer.apple.com/documentation/appintents/getting-started-with-the-app-intents-framework">Get started with App Intents</a></p></li><li><p><a href="https://developer.apple.com/documentation/appintents/making-app-entities-available-in-spotlight">Making app entities available in Spotlight</a></p></li></ul>]]></content:encoded></item><item><title><![CDATA[How I wiped data by seeding SQLite during CloudKit sync]]></title><description><![CDATA[a Walk Mate postmortem &#128298;&#127939;&#127995;&#8205;&#9794;&#65039;]]></description><link>https://emredegirmenci.substack.com/p/when-last-write-wins-wipes-icloud</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/when-last-write-wins-wipes-icloud</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Wed, 19 Aug 2026 16:04:33 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lrkV!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="pullquote"><p>Correction: An earlier draft blamed last-write-wins and argued that custom conflict resolution was required. Point-Free guys corrected the diagnosis: my app was seeding a CloudKit-synced table during startup. Last-edit-wins only resolved the conflict my startup code created.</p></div><p>Uninstalling and reinstalling an iOS app is boring. The local database disappears, the private CloudKit database remains, and the fresh install downloads the user&#8217;s data.</p><p>In <a href="https://apple.co/4mz7vev">Walk Mate</a>, that path destroyed lifetime stats. &#129327;&#128299;</p><p>Rule is simple:</p><blockquote><p>Seeding a CloudKit synced table on launch was wrong in my case. If CloudKit has no stats row, I should create it after the user&#8217;s first completed walk.</p></blockquote><h3>What disappeared</h3><p><a href="https://apple.co/4mz7vev">Walk Mate</a> uses Point-Free&#8217;s <a href="https://github.com/pointfreeco/sqlite-data">SQLiteData</a> <em><strong>SyncEngine, </strong></em>built on Apple&#8217;s <em><strong>CKSyncEngine. </strong></em>Its lifetime totals live in on synced row whose CloudKit record name is <em><strong>singleton:wm2_user_stats. </strong></em>Achievements are separate records, one <em><strong>wm2_milestones </strong></em>row per milestone.</p><h4>My Stats before uninstall (the numbers I later confirmed from CloudKit and Game Center):</h4><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!lrkV!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lrkV!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 424w, https://substackcdn.com/image/fetch/$s_!lrkV!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 848w, https://substackcdn.com/image/fetch/$s_!lrkV!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 1272w, https://substackcdn.com/image/fetch/$s_!lrkV!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!lrkV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png" width="2504" height="638" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:638,&quot;width&quot;:2504,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:111159,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/211556141?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1ec1ca7f-6072-47a3-b3a2-14e642cbfe34_2504x638.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!lrkV!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 424w, https://substackcdn.com/image/fetch/$s_!lrkV!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 848w, https://substackcdn.com/image/fetch/$s_!lrkV!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 1272w, https://substackcdn.com/image/fetch/$s_!lrkV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45197135-9a23-427e-ac40-76a0f7ac28a3_2504x638.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">CloudKit Dashboard #1</figcaption></figure></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!TEBC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!TEBC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 424w, https://substackcdn.com/image/fetch/$s_!TEBC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 848w, https://substackcdn.com/image/fetch/$s_!TEBC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 1272w, https://substackcdn.com/image/fetch/$s_!TEBC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!TEBC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png" width="2502" height="638" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:638,&quot;width&quot;:2502,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:107870,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/211556141?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b60201-ba4e-402a-8b49-4a646cd15d39_2502x638.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!TEBC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 424w, https://substackcdn.com/image/fetch/$s_!TEBC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 848w, https://substackcdn.com/image/fetch/$s_!TEBC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 1272w, https://substackcdn.com/image/fetch/$s_!TEBC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff0906b3c-8457-4a09-a8d6-6b907364e98d_2502x638.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">CloudKit Dashboard #2</figcaption></figure></div><p>My Stats before uninstall: 3367 medals, about 95 km, 174 walked routes, longest streak 9, and 19 of 31 achievements.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!W1N1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!W1N1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 424w, https://substackcdn.com/image/fetch/$s_!W1N1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 848w, https://substackcdn.com/image/fetch/$s_!W1N1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!W1N1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!W1N1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg" width="234" height="507.2977099236641" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:2556,&quot;width&quot;:1179,&quot;resizeWidth&quot;:234,&quot;bytes&quot;:1006755,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/211556141?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!W1N1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 424w, https://substackcdn.com/image/fetch/$s_!W1N1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 848w, https://substackcdn.com/image/fetch/$s_!W1N1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!W1N1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F213d3e66-5909-46de-a841-1bed829ae11f_1179x2556.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">My Stats Data in the app</figcaption></figure></div><p>I uninstalled the app from my iPhone and installed it again. Before doing that, there was an Xcode Simulator running Walk Mate which was also uninstalled and reinstalled the app and trying to fetch the same amount of data with a same iCloud account at the same time. Two fresh installs, two empty local databases, one private CloudKit record. Each launch authenticated with Game Center before CloudKit had hydrated SQLite. My Stats then showed 3367 Total Medals next to zeros for distance, routes, and streaks. Only one achievement appeared unlocked. That mixed screen was not a successful restore. It was a brand-new stats row: medals copied from Game Center, every other aggregate left at its default.</p><p>The Simulator finished that seed last. Its local row carried a newer edit time than the historical CloudKit record, and newer than whatever the iPhone had already sent. Under last-edit-wins, iCloud treated that zero-filled row as the most up-to-date data.</p><p>Game Center was never a complete backup. It could preserve a leaderboard medal total and some milestone lower bounds. It could not protect distance, route history, medal categories, or exact streak state.</p><h4><strong>My Stats after reinstall</strong>, for a while:</h4><p>Medals survived because they also live on a Game Center leaderboard. Everything else looked like a new player, reset &#129324;.<br><br>Then CloudKit caught up with the phone. The good remote record was overwritten with the empty local one:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!pJ1s!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!pJ1s!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 424w, https://substackcdn.com/image/fetch/$s_!pJ1s!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 848w, https://substackcdn.com/image/fetch/$s_!pJ1s!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 1272w, https://substackcdn.com/image/fetch/$s_!pJ1s!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!pJ1s!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png" width="2498" height="648" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/df8574d6-1873-416a-862d-7d35dec60682_2498x648.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:648,&quot;width&quot;:2498,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:111160,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/211556141?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b3817c1-3c47-45ce-b863-0491f9a7ec96_2498x648.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!pJ1s!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 424w, https://substackcdn.com/image/fetch/$s_!pJ1s!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 848w, https://substackcdn.com/image/fetch/$s_!pJ1s!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 1272w, https://substackcdn.com/image/fetch/$s_!pJ1s!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdf8574d6-1873-416a-862d-7d35dec60682_2498x648.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">CloudKit Dashboard #1</figcaption></figure></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!gBcr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!gBcr!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 424w, https://substackcdn.com/image/fetch/$s_!gBcr!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 848w, https://substackcdn.com/image/fetch/$s_!gBcr!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 1272w, https://substackcdn.com/image/fetch/$s_!gBcr!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!gBcr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png" width="2494" height="646" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f965fb95-74d7-4004-9631-87ba420095a7_2494x646.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:646,&quot;width&quot;:2494,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:106481,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/211556141?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9fb1b6f2-e1a6-4f29-97ed-5f0425092724_2494x646.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!gBcr!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 424w, https://substackcdn.com/image/fetch/$s_!gBcr!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 848w, https://substackcdn.com/image/fetch/$s_!gBcr!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 1272w, https://substackcdn.com/image/fetch/$s_!gBcr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff965fb95-74d7-4004-9631-87ba420095a7_2494x646.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">CloudKit Dashboard #2</figcaption></figure></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!KTwx!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!KTwx!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 424w, https://substackcdn.com/image/fetch/$s_!KTwx!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 848w, https://substackcdn.com/image/fetch/$s_!KTwx!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!KTwx!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!KTwx!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg" width="244" height="528.9770992366413" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:2556,&quot;width&quot;:1179,&quot;resizeWidth&quot;:244,&quot;bytes&quot;:1011681,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/211556141?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!KTwx!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 424w, https://substackcdn.com/image/fetch/$s_!KTwx!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 848w, https://substackcdn.com/image/fetch/$s_!KTwx!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!KTwx!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6b8bb10a-ad73-4caf-aad5-3b71367abd06_1179x2556.jpeg 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">My Stats data in the app</figcaption></figure></div><p>That last detail was the fingerprint. The Game Center seed writes the leaderboard total into <em><strong>beginnerMedalsCollected</strong></em> and zeros the other categories. CloudKit now looked exactly like that seed, not like a real walked one.</p><h3>The sequence that caused the loss</h3><p>The failure was in my app&#8217;s architecture and launch order, not in SQLiteData:</p><ol><li><p>Reinstalling removed the local SQLite database. CloudKit still held the healthy <em><strong>singleton:wm2_user_stats </strong></em>record.</p></li><li><p>Game Center authenticated before CloudKit had hydrated the empty local db.</p></li><li><p>The Game Center restore path tried to raise the medal total. Because no local stats row existed, the mutation helper materialized the singleton.</p></li><li><p>A db insert writes the whole row. The new row therefore contained 3367 medals plus zero walks, zero distance, and zero streaks.</p></li><li><p>That local row had a newer edit time than the historical CloudKit row. Under last-edit-wins, it won.</p></li><li><p><em><strong>SyncEngine </strong></em>uploaded the newer row, replacing the healthy server values with the startup defaults.</p></li></ol><p>The seed was intended as a partial store, but there is no partial insert of a nonexistent aggregate row. Creating the singleton turned every untouched field into a real, newly written zero.</p><p>Last-edit-wins did what it was designed to do. Custom conflict resolution might have reduced the damage (for example, by taking the maximum of monotonic counters) but it was not necessary to prevent this incident. The sufficient fix was to stop manufacturing synced state during startup.</p><h3>The sufficient fix</h3><p>In the cleanest design, an absent local stats row remains absent while CloudKit hydrates. If CloudKit contains the singleton, sync restores it. If CloudKit does not contain it, the app creates the row only when the user completes a real walk.</p><p>That means reads may temporarily present in-memory defaults before hydration but the app doesn&#8217;t persist those default as CloudKit state.</p><p>In practical terms:</p><ul><li><p>Don&#8217;t insert <em><strong>wm2_user_stats </strong></em>from launch setup, authentication, or UI initialization.</p></li><li><p>Don&#8217;t use a Game Center data as a reason to create that row.</p></li><li><p>Ideally, let the first completed walk create the singleton when no cloud record exists.</p></li></ul><p>That architectural rule fixes the root cause without requiring a new merge system.</p><h3>What the shipped code also does</h3><p>I kept the production fix deliberately conservative because this row contains a user&#8217;s lifetime progress.</p><p>The persistence layer now blocks a medal-only insert before the initial CloudKit hydration has finished:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;55568c0e-bf0a-4dc5-999d-c13a30953d8a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">let isInsert = existing == nil
if isInsert, !didFinishInitialCloudHydrate, !row.hasWalkProgress {
    return
}
try saveUserStatsRow(row, db: db)</code></pre></div><p>The reconciliation cycle fetches before it runs Game Center recovery and sends only after both the sync fetch and a direct remote lookup are conclusive:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;1fe5b117-4311-4101-8d3b-a37dd2fcc4db&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">let fetchOutcome = await fetchWithRetries(syncEngine: syncEngine, reason: reason)
let remoteLookup = await coalesceWalkAggregatesFromCloudKit()

if fetchSucceeded, remoteLookup != .failed {
    store.markInitialCloudHydrateFinished()
}

if store.hasFinishedInitialCloudHydrate {
    await restoreProgressFromGameCenterAchievements()
    await seedTotalMedalsFromGameCenterIfNeeded()
}

if fetchSucceeded, remoteLookup != .failed {
    try await syncEngine.sendChanges()
}</code></pre></div><p>The direct lookup reads <em><strong>singleton:wm2_user_stats</strong></em>, coalesces remote values into SQLite using max-style raises for monotonic totals, and treats an inconclusive lookup as a reason not to send. Game Center recovery also only raises values; it never lowers local progress.</p><p>These are useful safeguards against failed fetches, stale local state, and regressions in launch ordering. They should not obscure the simpler lesson: none of them makes startup seeding a sound design.</p><h3>A separate bug in a safety net</h3><p>SQLiteData stores synced payload fields in <em><strong>CKRecord.encryptedValues</strong></em>. My first direct CloudKit coalescing code read fields such as <em><strong>record["totalWalks"]</strong></em>, so it missed the real values and did nothing.</p><p>The corrected parser checks the encrypted payload:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;4cf13f43-aeee-4db3-9a75-4062d1fbc4e5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if let encrypted = record.encryptedValues[key] {
    return encrypted
}
return record[key]</code></pre></div><p>CloudKit also does not allow the same key in both regular and encrypted field containers, as discussed in <a href="https://github.com/pointfreeco/sqlite-data/discussions/244">SQLiteData discussion #244</a>.</p><p>That parsing mistake broke a safety net. It did not cause the overwrite. Had I never seeded the synced singleton on launch, the safety net would not have been needed to prevent this loss.</p><h3>Why I kept the singleton</h3><p>One row per completed walk is a stronger long-term data model. A single bad write cannot replace an entire walking history, totals can be derived from immutable events, and conflicts are isolated to individual walks.</p><p>I did not make that migration as part of this repair because the existing app was aggregate-oriented from end-2-end. The stored data, stats UI, widgets, milestone calculations, streak state, and Game Center integration all expected one set of accumulated values. Keeping the singleton was the smallest patch I could ship safely.</p><p>That choice has a cost. A per-walk model would provide better failure isolation and let me recompute totals, but adopting it properly requires:</p><ul><li><p>a schema migration</p></li><li><p>rules for deriving current and longest streaks</p></li><li><p>a plan for users who only have historical aggregates, not old walk events</p></li><li><p>updates across the UI, widgets, milestones, and reconciliation code</p></li><li><p>more CloudKit records</p></li></ul><p>The singleton is not the ideal event history. It was the lowest-risk repair for the data model already in production.</p><h3>Verifying the repair</h3><p>After the fix, reinstalling hydrated the existing CloudKit record instead of uploading a new zero-filled one. After a later walk, the healthy values were:</p><ul><li><p><em><strong>3,367</strong></em> medals</p></li><li><p>about 95 km walked (<em><strong>95,365 m</strong></em> in CloudKit)</p></li><li><p><em><strong>174</strong></em> completed routes</p></li><li><p>current streak of <em><strong>1</strong></em> day</p></li><li><p>longest streak of <em><strong>9</strong></em> days</p></li><li><p><em><strong>19 of 31 </strong></em>achievements</p></li></ul><p>Another uninstall and reinstall restored those values correctly.</p><div><hr></div><h3>Takeaways</h3><ol><li><p><strong>An empty local database is not permission to create defaults in a synced table.</strong> It may simply be waiting for CloudKit.</p></li><li><p><strong>Create synced records from real domain events.</strong> For Walk Mate, the first completed walk is a valid reason to create stats; app launch is not.</p></li><li><p><strong>Last-edit-wins was the messenger, not the defect.</strong> My newer local row accurately won the conflict my code introduced.</p></li><li><p><strong>A singleton aggregate concentrates risk.</strong> Event rows provide better isolation and recoverability, but moving to them is a real data-model migration.</p></li><li><p><strong>Backups need explicit boundaries.</strong> Game Center preserved a leaderboard total and some achievement information, not distance, route history, medal categories, or exact streak state.</p></li><li><p><strong>Read the storage layer you actually use.</strong> SQLiteData&#8217;s CloudKit payload is in <em><strong>encryptedValues</strong></em>; querying another container can silently disable recovery logic.</p></li></ol><p>The durable rule is much less complicated than the recovery code I eventually shipped: when CloudKit owns a table, do not race it with startup seeds.</p>]]></content:encoded></item><item><title><![CDATA[Why does iCloud sync get stuck sometimes?]]></title><description><![CDATA[A silent sync problem that can haunt anyone with two Apple devices]]></description><link>https://emredegirmenci.substack.com/p/why-does-icloud-sync-get-stuck-sometimes</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/why-does-icloud-sync-get-stuck-sometimes</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Mon, 03 Aug 2026 08:47:16 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!_qBH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Have you ever wondered why does iCloud data sync stuck and sometimes not move forward while you are transferring your photos between MacOS Photos app to the Desktop or other Finder files by dragging? I had this too and I&#8217;ve been having almost everyday. Also, since I have no backend development experience and a hater of a vibe-coding by completely relying on LLMs and don&#8217;t want to spend money for 3rd party server providers, I use iCloud and CloudKit public/private database as a backend which is fully native Apple framework and data syncing tool in my apps. When I re-install <a href="https://apple.co/4mz7vev">Walk Mate</a> on different simulators or physical iOS devices for testing purposes or in real life production, I see the following screen while data sync in progress and it sometimes get stuck and takes too long. Then, boom, where did my stuff go???</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!OHw7!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!OHw7!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 424w, https://substackcdn.com/image/fetch/$s_!OHw7!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 848w, https://substackcdn.com/image/fetch/$s_!OHw7!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 1272w, https://substackcdn.com/image/fetch/$s_!OHw7!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!OHw7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png" width="534" height="224" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/33c8d319-2515-45b2-99c6-4c956400a049_534x224.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:224,&quot;width&quot;:534,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:23436,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/208892130?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!OHw7!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 424w, https://substackcdn.com/image/fetch/$s_!OHw7!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 848w, https://substackcdn.com/image/fetch/$s_!OHw7!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 1272w, https://substackcdn.com/image/fetch/$s_!OHw7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33c8d319-2515-45b2-99c6-4c956400a049_534x224.png 1456w" sizes="100vw" fetchpriority="high"></picture><div></div></div></a></figure></div><p>In order to force that iCloud data syncing I even provided a forcing option to users.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!_qBH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!_qBH!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 424w, https://substackcdn.com/image/fetch/$s_!_qBH!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 848w, https://substackcdn.com/image/fetch/$s_!_qBH!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 1272w, https://substackcdn.com/image/fetch/$s_!_qBH!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!_qBH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png" width="536" height="398" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:398,&quot;width&quot;:536,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:44286,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/208892130?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!_qBH!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 424w, https://substackcdn.com/image/fetch/$s_!_qBH!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 848w, https://substackcdn.com/image/fetch/$s_!_qBH!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 1272w, https://substackcdn.com/image/fetch/$s_!_qBH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F31d7ef06-0047-4a38-8a5f-bd44b82fa909_536x398.png 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>So the UI keeps smiling. You keep waiting. Nothing arrives. You start questioning your life choices &#129300;<br><br><a href="https://apple.co/4mz7vev">Walk Mate</a> is doing the normal responsible things: save locally first, mirror into iCloud-ish places, merge when the other device finally sees the data. For avoided segments I even have that &#8220;syncing from iCloud&#8230;&#8221; kind of waiting state because ubiquity files really can be &#8220;not downloaded yet&#8221;. But there is a ceiling to what an app can explain. So of course people blame the app. I would too. If your favorite route is missing, <a href="https://apple.co/4mz7vev">Walk Mate</a> is the face of the crime &#128373;&#127995;&#8205;&#9794;&#65039;<br><br>Then I heard a fix from a super talented software developer who basically said: once he understood Photos/iCloud were getting stuck because the UDP/QUIC path was unhealthy and Apple wasn&#8217;t falling back in a useful way, <strong>he basically just shut UDP down on his network and forced everything onto TCP.</strong> <br><br>Under the hood a lot of this content traffic goes through Apple&#8217;s system daemons; <em><strong>cloudd, nsurlsessiond. </strong></em>And modern Apple networking loves <strong>HTTP/3 over QUIC, </strong>which basically means UDP 443 instead of the old reliable TCP lane. QUIC is supposed to be the cool fast future. Until it isn&#8217;t.</p><p>There&#8217;s a whole Apple forums thread about this exact &#8220;silent upload deadlock&#8221; energy with stale HTTP/3 sessions. <br><br>If you want the deep technical rabbit hole:<em> <strong><a href="https://developer.apple.com/forums/thread/822534">Apple Developer forums thread</a>.</strong></em></p>]]></content:encoded></item><item><title><![CDATA[Is the mainQueue serial or concurrent? ]]></title><description><![CDATA[I&#8217;m here with another popular iOS interview question that I get asked frequently.]]></description><link>https://emredegirmenci.substack.com/p/is-the-mainqueue-serial-or-concurrent</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/is-the-mainqueue-serial-or-concurrent</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Mon, 27 Jul 2026 14:20:07 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!mSue!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I&#8217;m here with another popular iOS interview question that I get asked frequently.<br><br>&#128104;&#127995;&#8205;&#127979; <em><strong>&#8221;Is the mainQueue serial or concurrent?&#8221;</strong></em></p><blockquote><p>Short answer: <strong>the main queue is serial. &#128512;</strong></p></blockquote><p>If you think about the mainQueue, it is designed for the UI. If you want to make changes on the UI, their order you dispatched asynchronous action in the mainQueue still matters. For instance, when you run an animation or update labels, images, or navigation, you don&#8217;t want those updates racing each other. So the main queue works like one-after-another: each task must complete before the next task is able to start. That&#8217;s exactly what a serial queue gives you. On the other hand the <strong>GlobalQueue</strong>s are in general concurrent with tasks with respect to each other&#8217;s completion. Because you can dispatch offline things of main thread and do something maybe super intensive (networking, decoding, file I/O etc.) and then when it finished you can update the UI. Those are usually concurrent on parallel depending on the needs. </p><p>A quick mental model that helps in interviews:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!mSue!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!mSue!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 424w, https://substackcdn.com/image/fetch/$s_!mSue!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 848w, https://substackcdn.com/image/fetch/$s_!mSue!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 1272w, https://substackcdn.com/image/fetch/$s_!mSue!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!mSue!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png" width="1456" height="324" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:324,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:59147,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/197694914?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc715d75b-577d-4be8-aca1-2383c05bc484_1608x358.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!mSue!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 424w, https://substackcdn.com/image/fetch/$s_!mSue!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 848w, https://substackcdn.com/image/fetch/$s_!mSue!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 1272w, https://substackcdn.com/image/fetch/$s_!mSue!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc8a86ac3-4300-4395-9f5f-9d62da280d21_1608x358.png 1456w" sizes="100vw" fetchpriority="high"></picture><div></div></div></a></figure></div><p>Also don&#8217;t mix up <strong>serial/concurrent</strong> with <strong>sync/async</strong>. Those are different axes:</p><ul><li><p><strong>Serial vs concurrent</strong> &#8594; how the <em>queue</em> runs tasks (one-by-one vs overlapping)</p></li><li><p><strong>sync vs async</strong> &#8594; whether the <em>caller waits</em></p><ul><li><p><code>sync</code> &#8594; wait until that work finishes</p></li><li><p><code>async</code> &#8594; submit the work and continue</p></li></ul></li></ul><p><span>So </span><em><strong>DispatchQueue.main.async { ... }</strong></em><span> still runs on the </span><strong><span>serial main queue</span></strong><span>. Async only means </span><strong><span>&#8220;don&#8217;t block the caller while waiting to schedule it.&#8221;</span></strong><span> It does </span><strong><span>not</span></strong><span> mean &#8220;run this in the background.&#8221;</span></p><p>That distinction also explains a classic crash/freeze: calling <em><strong>DispatchQueue.main.sync</strong></em> while you are already on the main thread. The main queue is waiting for the sync block to finish but the sync block <strong>can&#8217;t start until the main queue is free</strong>, it is <strong>deadlock</strong>. Prefer <em><strong>main.async</strong></em> for UI updates and if you must use <em><strong>sync</strong></em>, never sync a queue against itself.</p><h3>Where does this land with modern Swift? </h3><p>Actors protect mutable state by serializing access, one caller at a time. An actor gives us its own isolation domain and that work usually runs off the main thread unless the actor is main-actor isolated. So conceptually it&#8217;s similar energy to a serial queue: controlled, ordered access. But the main actor / main queue remains special because UIKit/SwiftUI UI updates still need that serial main-thread world.</p><div><hr></div><p><strong><span>Interview takeaway:</span></strong> Main queue = serial, UI-bound, order matters. Global queues = concurrent, good for heavy work. Sync/async is about waiting, not about which queue you are on.</p>]]></content:encoded></item><item><title><![CDATA[Are conditional view modifiers a good idea in SwiftUI?]]></title><description><![CDATA[I&#8217;ve been interviewing with companies for the past 8 months (still unemployed in the current tough market!]]></description><link>https://emredegirmenci.substack.com/p/are-conditional-view-modifiers-a</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/are-conditional-view-modifiers-a</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Mon, 20 Jul 2026 08:53:30 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!gAHy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I&#8217;ve been interviewing with companies for the past 8 months (<strong>still unemployed in the current tough market! You can <a href="https://www.linkedin.com/in/aemrdgrmnci">hire me</a>!</strong>) now and nowadays I&#8217;m writing articles about my experiences around interview questions and situations that I faced while building my personal fully native iOS apps with Swift, SwiftUI and other modern approaches. And today&#8217;s one is one of the questions that I got recently <em><strong>&#8220;Are conditional view modifiers a good idea in SwiftUl?</strong></em></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!gAHy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!gAHy!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 424w, https://substackcdn.com/image/fetch/$s_!gAHy!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 848w, https://substackcdn.com/image/fetch/$s_!gAHy!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 1272w, https://substackcdn.com/image/fetch/$s_!gAHy!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!gAHy!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png" width="1456" height="595" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:595,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:85909,&quot;alt&quot;:&quot;The code that you never ever use!&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/204654296?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="The code that you never ever use!" title="The code that you never ever use!" srcset="https://substackcdn.com/image/fetch/$s_!gAHy!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 424w, https://substackcdn.com/image/fetch/$s_!gAHy!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 848w, https://substackcdn.com/image/fetch/$s_!gAHy!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 1272w, https://substackcdn.com/image/fetch/$s_!gAHy!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9aa372d7-70c7-46f9-a30d-94c656543bfe_1780x728.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">This screenshot was taken from objc.io</figcaption></figure></div><p>It is a short question with a long answer. Most people say it depends&#8230; and move on. Chris Eidhof wrote the classic take on this years ago: <em><a href="https://www.objc.io/blog/2021/08/24/conditional-view-modifiers/">Why Conditional View Modifiers are a Bad Idea (objc.io)</a></em>. Apple&#8217;s <a href="https://www.avanderlee.com/ai-development/using-xcode-27s-agent-skills-in-claude-codex-and-cursor/">Xcode 27 swiftui-expert-skill</a> now says the same thing under <em><strong>Prefer Modifiers Over Conditional Views</strong></em>. I have been applying both while reviewing <a href="https://apple.co/4mz7vev">Walk Mate&#8217;s</a> map screen and this post is the answer I would give in that interview. </p><h3>The Core Idea</h3><p>In SwiftUI, views are value types (struct). They do not have object identity like UIKit. SwiftUI figures out <em><strong>&#8220;sameness&#8221;</strong></em> from structure and type. When you animate, it needs to compare the view before/after and interpolate between those values.</p><blockquote><p><em>As it explained very well in objc.io&#8217;s blog post, an if/else branch produces _ConditionalContent. That is an enum: either the true branch or the false branch. When the condition flips, SwiftUI does not interpolate. It removes one view and inserts another. Default transition: fade. And any @State / @StateObject sitting on that position gets reset, because as far as SwiftUI is concerned a new view just appeared.</em></p></blockquote><h4>The Interview Trap: .if and applyIf</h4><p>A lot of codebases still have something like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;446406e1-2911-46aa-ae30-8725c497709e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// Please don&#8217;t use this:
extension View {
    @ViewBuilder
    func `if`&lt;T: View&gt;(_ condition: Bool, transform: (Self) -&gt; T) -&gt; some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}</code></pre></div><p>Or the <em><strong>applyIf</strong></em> variant from countless blog posts. It feels clever. It is also the exact pattern Apple calls out as problematic!</p><h4>Why? </h4><p>The return type changes per branch. One branch is <em><strong>T (the transformed view)</strong></em>. The other is <em><strong>Self</strong></em>. Outermost type becomes <em><strong>_ConditionalContent</strong></em>. Identity breaks. Animations break. State can reset.</p><p>objc.io&#8217;s example makes it obvious. Conditionally framing a Rectangle with applyIf fades. Writing .frame(width: myState ? 100 : nil) keeps one ModifiedContent type and animates smoothly.</p><h3>Apple&#8217;s guidance matches that:</h3><ul><li><p>Prefer always-present modifiers with ternary values:</p></li></ul><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;8ac51bc0-8ef2-46bd-8567-5ab0be6256d8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Text(&#8221;Hello&#8221;)
    .opacity(isHighlighted ? 1 : 0.5)

Text(&#8221;Hello&#8221;)
    .foregroundStyle(isError ? .red : .primary)</code></pre></div><p>When writing new code, never reach for a <em><strong>.if</strong></em> modifier. When reviewing existing code that already uses one, point out the identity and animation risk and show the ternary alternative but don&#8217;t silently refactor it inside an unrelated PR. Swapping it can change behavior (state resets, transition timing). That belongs in its own focused edit.</p><ul><li><p>Prefer Modifiers Over Conditional Views</p></li></ul><p>When you introduce a branch, are you representing multiple views or two states of the same view?</p><p>Same view, different states &#8594; prefer a no-effect modifier:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;a34c11dc-759b-40d8-8328-3134d9851ac0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">SomeView()
    .opacity(isVisible ? 1 : 0)</code></pre></div><p>Avoid create/destroy for visibility:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;b50b41e3-c10d-4d31-8fd1-d00d6a62a0f7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if isVisible {
    SomeView()
}</code></pre></div><h4>Why?</h4><p>Conditional inclusion can lose state, hurt animation, and break identity. Modifiers keep the same view across state changes.</p><p>Conditionals are appropriate when you truly have different views:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;c5ba3f42-405e-4a12-b638-822e0f046403&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if isLoggedIn {
    DashboardView()
} else {
    LoginView()
}

if let user {
    UserProfileView(user: user)
}</code></pre></div><p>That last one matters. Optional content is fine. Fundamentally different screens are fine. <em><strong>&#8220;Hide this card for a second&#8221;</strong></em> is usually not, that is opacity (and often <em><strong>allowsHitTesting(false)</strong></em> when it should not receive taps).</p><h3>What I Found in Walk Mate</h3><p>I put breakpoints on a few conditionals in <em><strong>ContentView</strong></em> and <em><strong>AdventureCompletedView</strong></em> and asked myself: is this two states of the same view or truly different views?</p><ol><li><p><strong>Good: same view, different visual state</strong></p></li></ol><p>In <em><strong>MyStatsView</strong></em>, locked milestones stay in the tree and only dim:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;b60d108d-ea06-4939-a28c-cb62850b361d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">.opacity(milestone.unlocked ? 1.0 : 0.7)</code></pre></div><p>Same pattern in <em><strong>MapStyleView</strong></em> for non-interactive rows:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;27c75b77-c5a8-4a25-bacb-25acdad3f979&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">.opacity(isInteractive ? 1 : 0.55)</code></pre></div><p>That is exactly Apple&#8217;s <em><strong>&#8220;no-effect modifier&#8221;</strong></em> advice. One view identity. Value changes.</p><ol start="2"><li><p><strong>Correct conditionals: fundamentally different UI modes</strong></p></li></ol><p>In <em><strong>ContentForegroundStack</strong></em> I switch between drawing and the normal bottom chrome:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;5e1dfc93-bb31-474d-b1b2-3ad52e3493fd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if store.drawRoute.isDrawingRoute {
    DrawRouteOverlayView(store: store)
} else if !store.avoidance.isEditingAvoidedLocations {
    BottomControls(...)
}</code></pre></div><p><em><strong>DrawRouteOverlayView</strong></em> and <em><strong>BottomControls</strong></em> are not two opacities of the same thing. They are different modes of the map UI. Same idea for <em><strong>MapControlsView</strong></em> and <em><strong>AdventureStatsView</strong></em> disappearing while the user draw or mark avoidance. Those branches are appropriate.</p><p>Optional content is also fine:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;21218670-9949-4099-8cd1-74522c7dbfe2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if let medal {
    MedalCollectionToast(...)
}</code></pre></div><p>That matches Apple&#8217;s <em><strong>if let user</strong></em> example.</p><ol start="3"><li><p><strong>The gray area: overlays that come and go</strong></p></li></ol><p><em><strong>ContentMainView</strong></em> still has:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;bf0d9ff2-ddc0-4400-8f65-0d23c9c90375&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if store.routeGeneration.isGeneratingRoutes {
    LoadingOverlay()
}

if store.walkSession.showAdventureCompleted {
    AdventureCompletedView(store: store)
}</code></pre></div><p>And inside <em><strong>AdventureCompletedView</strong></em>, confetti is also gated:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;c5042d67-6b60-40e5-820a-6175fda56cf0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if showConfetti {
    ConfettiView()
}</code></pre></div><p>Are these <em><strong>&#8220;wrong&#8221;</strong></em>? Not automatically. Loading and adventure-completed are closer to modal layers than to a button that toggles highlight. Creating them when needed is readable and for <em><strong>LoadingOverlay</strong></em> there is little state to preserve.</p><p>But <em><strong>AdventureCompletedView</strong></em> holds animation state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;c32e6b91-4f0b-4816-bad3-88c968be225e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@State private var showConfetti = false
@State private var cardScale: CGFloat = 0.8
@State private var cardOpacity: Double = 0</code></pre></div><p>The outer if in <em><strong>ContentMainView</strong></em> creates and destroys that whole view. That resets <em><strong>@State</strong></em> every time the overlay appears which we actually rely on because .<em><strong>onAppear</strong></em> kicks the spring for <em><strong>cardScale/cardOpacity</strong></em>. So the conditional is doing intentional lifecycle work not just <em><strong>&#8220;hide me&#8221;</strong></em>.</p><p>Still, there is a smell &#129448;: <em><strong>AdventureCompletedView</strong></em>&#8217;s body also checks <em><strong>showAdventureCompleted</strong></em> and <em><strong>selectedRoute</strong></em> again. Double gate. The parent already decided the overlay should exist. The child repeating the same if means an empty branch when state is inconsistent and it keeps the <em><strong>&#8220;presence&#8221;</strong></em> question inside a view that also owns animation state.</p><p>For confetti specifically, Apple&#8217;s opacity guidance fits better than create/destroy:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;57e91d75-3577-4b75-baa0-35322eef994c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">ConfettiView()
    .opacity(showConfetti ? 1 : 0)
    .allowsHitTesting(false)</code></pre></div><p>Same view. No identity flip when the Bool toggles. If <em><strong>ConfettiView</strong></em> ever grows internal state, you won&#8217;t reset it by accident.</p><blockquote><p><strong>Interview answer in one line:</strong> <em>use if when the view graph is genuinely different; use modifiers when you are only changing how the same view looks or whether it receives interaction.</em></p></blockquote><h3>Practical Checklist (What I Use in Code Reviews Now)</h3><p>1. Ask first: same view, two states or two different views?</p><p>2. Prefer ternary values on always-present modifiers (opacity, foregroundStyle, frame with nil etc.).</p><p>3. Never add a new <em><strong>.if / applyIf View</strong></em> extension. If one exists, call out the risk; refactor in a dedicated change.</p><p>4. Use <em><strong>if / else</strong></em> for real mode switches and <em><strong>if let</strong></em> for optional content.</p><p>5. Be careful when <em><strong>@State</strong></em> lives inside a view that is itself behind an if, otherwise appearance resets state. Sometimes you want that (entrance animation). Sometimes you just lost the user&#8217;s text field.</p><p>6. Do not &#8220;fix&#8221; every overlay if in the app overnight. Loading and completion modals can stay as conditionals when create/destroy is the lifecycle you want.</p><div><hr></div><h4>The Answer I Would Give In an Interview</h4><p>No, not the <em><strong>applyIf / .if</strong></em> extension kind. They wrap <em><strong>if/else</strong></em> around modifiers, change the return type per branch, break structural identity and turn smooth property animations into transitions. <strong>Prefer modifiers</strong> that are always applied with values that change.</p><p>Conditional views (if in a body) are fine when the branches are actually different views or optional content. They are the wrong tool when you only mean &#8220;this same view, hidden or dimmed.&#8221;</p><p>That is Chris Eidhof&#8217;s objc.io argument. It is also what Apple wrote into Xcode 27&#8217;s swiftui-expert-skill. In a map-heavy app like Walk Mate, the win is knowing which of your <em><strong>if</strong></em>s are mode switches, which are optional content and which are visibility hacks that should have been opacity all along.</p><p><strong>Sources</strong> </p><ul><li><p><a href="https://www.objc.io/blog/2021/08/24/conditional-view-modifiers/">Why Conditional View Modifiers are a Bad Idea</a></p></li><li><p>Prefer Modifiers Over Conditional Views - Apple / Xcode 27 swiftui-expert-skill (view-structure.md)</p><p></p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;cf6482d7-12db-44d8-91d8-5fbb0d9f8706&quot;,&quot;caption&quot;:&quot;Apple recently added this to their Xcode 27 coding skills guidance, and it started a good conversation on X. Vincent shared it and Shannon asked a fair question: if you split a large view, does @ViewBuilder help with identity?&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;lg&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Splitting Large SwiftUI Views in the Apple's way&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:78728032,&quot;name&quot;:&quot;Emre Degirmenci&quot;,&quot;bio&quot;:&quot;iOS Engineer with 8+ years of experience. Writing about building apps with Swift, SwiftUI, and UIKit for Apple platforms, mainly iOS.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/40cb062b-0d35-4584-a19e-6dc2d73314ef_3546x3546.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-07-07T11:40:26.572Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!wOfj!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fpbs.substack.com%2Fmedia%2FHMDvSVCbAAAfJY1.jpg&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://emredegirmenci.substack.com/p/splitting-large-swiftui-views-in&quot;,&quot;section_name&quot;:null,&quot;video_upload_id&quot;:null,&quot;id&quot;:204279449,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:15,&quot;comment_count&quot;:1,&quot;publication_id&quot;:8687558,&quot;publication_name&quot;:&quot;Emre Degirmenci&quot;,&quot;publication_logo_url&quot;:&quot;https://substackcdn.com/image/fetch/$s_!ozjy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F40cb062b-0d35-4584-a19e-6dc2d73314ef_3546x3546.jpeg&quot;,&quot;belowTheFold&quot;:true,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div></li></ul>]]></content:encoded></item><item><title><![CDATA[Spec Kit (Spec-Driven Development) in an iOS Project]]></title><description><![CDATA[From vibe coding to Spec-Driven Development: shipping a SwiftUI + TCA feature with GitHub Spec Kit inside Cursor]]></description><link>https://emredegirmenci.substack.com/p/spec-kit-spec-driven-development</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/spec-kit-spec-driven-development</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Mon, 13 Jul 2026 12:42:27 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!6QYs!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this post, I wanna tell you something about a paradigm that I didn&#8217;t know existed, <strong>Spec-Driven Development</strong>. Last week, a friend of mine told me there is an open source library called <a href="https://github.com/github/spec-kit">Spec Kit</a> provided by GitHub and <span>at his company they don&#8217;t really &#8220;prompt&#8221; AI anymore. They run it against a spec.</span> I directly dug into it since it is way more automated than the casual vibe-coding and tried to apply it in my toy project. </p><p>The main difference between SDD and vibe-coding is vibe-coding excels at rapid prototyping and SDD is essential for complex production systems to prevent AI hallucinations, architectural drift and technical debt. <strong>Spec Kit</strong> <strong>is a workflow that executes scripts and very fine-tuned prompts to produce high quality context for your coding agent and the lightweight framework compared to the other ones.</strong> And this is what GitHub says:</p><blockquote><p>Spec-Driven Development <strong>flips the script</strong> on traditional software development. For decades, code has been king &#8212; specifications were just scaffolding we built and discarded once the &#8220;real work&#8221; of coding began. Spec-Driven Development changes this: <strong>specifications become executable</strong>, directly generating working implementations rather than just guiding them. </p></blockquote><p><span>AI-assisted iOS development for me is still typing a prompt in Cursor and pray </span>&#128720;<span> Type a prompt, skim the diff, accept, re-prompt when the tests break.</span></p><p><span>Spec-Driven Development (SDD) is the pushback. And GitHub&#8217;s </span><a href="https://github.com/github/spec-kit"><span>Spec Kit</span></a><span> is the toolkit that makes it usable inside the editor you already have. </span></p><p><span>This post is a walkthrough of installing Spec Kit into an existing ~40k-line SwiftUI + TCA app </span><a href="https://apps.apple.com/us/app/walkmate-route-generator/id6739468121"><span>Walk Mate</span></a><span> and for learning purposes shipping one small feature (Favorites sort + search) end-to-end through the full flow. Every step will be tied to a real file in the repo.</span></p><h3><span>What SDD actually is (and what Spec Kit gives you)</span></h3><p><span>SDD is not </span><em><strong><span>write more comments before you code</span></strong></em><span>. It is a fixed pipeline:</span></p><p><strong><span>1. Constitution:</span></strong><span> the rules of your project</span></p><p><strong><span>2.</span></strong><span> </span><strong><span>Specification:</span></strong><span> user stories, requirements, success criteria</span></p><p><strong><span>3.</span></strong><span> </span><strong><span>Plan:</span></strong><span> the technical approach with your chosen stack (SwiftUI + TCA)</span></p><p><strong><span>4.</span></strong><span> </span><strong><span>Tasks:</span></strong><span> the plan decomposed into buildable phases (spec + plan)</span></p><p><strong><span>5.</span></strong><span> </span><strong><span>Implementation:</span></strong><span> one phase at a time</span></p><p><span>Each stage validated before the next. </span></p><p><span>Spec Kit is GitHub&#8217;s implementation of this pipeline. You&#8217;ll get:</span></p><ul><li><p><span> A CLI </span><em><strong><span>specify</span></strong></em><span> that scaffolds the artifacts into your project</span></p></li><li><p><span>Markdown .md templates for each stage (constitution, spec, plan, tasks)</span></p></li><li><p><span>Editor-native slash commands so Cursor can drive the flow with a shared vocabulary</span></p></li></ul><p><span>The command surface I used in Cursor:</span></p><p><strong><span>Command                       | Purpose</span></strong></p><p><em><strong><span>/speckit-constitution</span></strong></em><span>      | Project principles and hard constraints</span></p><p><em><strong><span>/speckit-specify</span></strong></em><span>               | User stories, requirements, success criteria</span></p><p><em><strong><span>/speckit-clarify</span></strong></em><span>                | Structured Q&amp;A to close ambiguity before planning</span></p><p><em><strong><span>/s</span>peckit-plan</strong></em><code>        </code><span>| Technical plan with the chosen stack</span></p><p><em><strong><span>/speckit-tasks</span></strong></em><span>                   | Decompose the plan into buildable phases</span></p><p><em><strong><span>/speckit-analyze</span></strong></em><span>              | Cross-artifact consistency check</span></p><p><em><strong><span>/speckit-implement</span></strong></em><span>         | Execute one phase at a time</span></p><h3><span>Setup in an existing project</span></h3><p><span>You need three things: </span><a href="https://docs.astral.sh/uv/"><span>uv</span></a><span> (Astral&#8217;s Python package manager), </span><strong><span>Python 3.11+</span></strong><span>, and </span><strong><span>Cursor</span></strong><span>. Install the CLI once:</span></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;52f2060e-a3c0-4278-9154-f3bc12918930&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">&gt; uv tool install specify-cli --from git+https://github.com/github/spec-kit.git</code></pre></div><p><span>Then, inside your Xcode project&#8217;s root:</span></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;f3680282-e882-4cfd-8abb-52b8e4b9c0c3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">&gt; cd MyExistingIOSApp
&gt; specify init . --integration cursor-agent --force</code></pre></div><p><span>Two things worth calling out here. </span><em><strong><span>--force</span></strong></em><span> is necessary because the directory isn&#8217;t empty Spec Kit merges its templates in rather than wiping. And </span><em><strong><span>cursor-agent</span></strong></em><span> is the skills-based integration. It installs 10 skills under </span><em><strong><span>.cursor/skills/</span></strong></em><span>, one per slash command so Cursor&#8217;s palette picks them up automatically.</span></p><p><span>After init, the new tree looks like this:</span></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!6QYs!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6QYs!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 424w, https://substackcdn.com/image/fetch/$s_!6QYs!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 848w, https://substackcdn.com/image/fetch/$s_!6QYs!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 1272w, https://substackcdn.com/image/fetch/$s_!6QYs!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!6QYs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png" width="1182" height="596" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:596,&quot;width&quot;:1182,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:87735,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/206353670?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!6QYs!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 424w, https://substackcdn.com/image/fetch/$s_!6QYs!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 848w, https://substackcdn.com/image/fetch/$s_!6QYs!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 1272w, https://substackcdn.com/image/fetch/$s_!6QYs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c91d7ed-248c-4851-8e8f-1ea6896fbb64_1182x596.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><span>Two side quests before you move on:</span></p><ul><li><p><span>First, </span><em><strong><span>gitignore:</span></strong></em><span> Spec Kit&#8217;s post-install output specifically warns that agent folders can hold credentials and suggests </span><em><strong><span>.gitignore</span></strong></em><span> -ing </span><em><strong><span>.cursor/</span></strong></em><span>. I already ignored that folder which means my installed skills are per-clone. For a solo indie app that&#8217;s fine. For a team I would commit at least </span><em><strong><span>.cursor/skills/</span></strong></em><span> so everyone has the same slash commands. Make it an explicit decision.</span></p></li><li><p><span>Second, Xcode 16+&#8217;s file-system-synchronized groups. Any new </span><em><strong><span>.swift</span></strong></em><span> file dropped into a synchronized folder is auto-added to the target. When </span><em><strong><span>/speckit-implement</span></strong></em><span> creates </span><strong><span>FavoritesSortOption.swift</span></strong><span>, I don&#8217;t need to touch </span><em><strong><span>project.pbxproj</span></strong></em><span>. If you&#8217;re on a legacy project with manually managed file references, budget extra time for this friction.</span></p></li></ul><h3><span>The constitution: fold in what your project already enforces</span></h3><p><span>This is the most crucial section for me because it&#8217;s where SDD stops being a novelty and starts paying rent </span>&#129297;<span> This usually set up once at the very start of your project.</span></p><p><span>In a greenfield app the constitution is aspirational: rules you </span><strong><span>hope</span></strong><span> to follow. In an existing app it&#8217;s descriptive: rules the codebase </span><strong><span>already</span></strong><span> enforces (tribal knowledge, PR comments or Cursor rules files) but in a form no AI agent can read. Writing the constitution is the act of making that knowledge machine-readable for every future agent session.</span></p><p><span>I ended up with four principles in </span><em><strong><span>.specify/memory/constitution.md:</span></strong></em></p><h4><strong><span>1. SwiftUI + TCA architecture</span></strong></h4><p><em><strong><span>@Reducer</span></strong></em><span>, </span><em><strong><span>@ObservableState</span></strong></em><span>, delegate actions, </span><em><strong><span>@Dependency</span></strong></em><span> clients. This is descriptive: </span><em><strong><span>FavoriteRoutesFeature.swift</span></strong></em><span> already uses </span><em><strong><span>@Dependency(\.persistenceClient)</span></strong></em><span> and a </span><em><strong><span>Delegate</span></strong></em><span> enum to talk to its parent. The constitution just names the pattern.</span></p><h4><strong><span>2. Localization completeness</span></strong></h4><p><span>Every user-facing string must exist in all 15 locales (da, de, es, fr, it, ja, ko, nl, pl, pt, ru, sv, tr, zh-Hans, zh-Hant) inside </span><em><strong><span>Localizable.xcstrings</span></strong></em><span> with </span><em><strong><span>&#8221;state&#8221;: &#8220;translated&#8221;</span></strong></em><span>. This is a verbatim promotion of </span><em><strong><span>.cursor/rules/localization.mdc</span></strong></em><span>  same rule but now the AI sees it at every SDD step not only when it happens to load that rule file.</span></p><h4><strong><span>3. No magic numbers in UI</span></strong></h4><p><span>Layout, spacing, corner radius, animation timing become named constants under </span><em><strong><span>AppConstants.UI</span></strong></em><span>, </span><em><strong><span>Typography</span></strong></em><span>, </span><em><strong><span>AppColors</span></strong></em><span> or a feature-scoped </span><em><strong><span>UIConstants</span></strong></em><strong><span>.&lt;Feature&gt;</span></strong><span> enum.</span></p><h4><strong><span>4. Test-first for reducer logic</span></strong></h4><p><span>Swift Testing + TCA </span><em><strong><span>TestStore</span></strong></em><span> before or alongside any reducer change.</span></p><h3><span>Driving one real feature through the pipeline</span></h3><p><span>The feature: add user-selectable sorting (by date saved, name, distance) and search to the Favorites list in Walk Mate. Small, self-contained, real. Two production files touched at the start </span><strong><span>FavoriteRoutesFeature.swift</span></strong><span> and </span><strong><span>FavoriteRoutesView.swift</span></strong><span>.</span></p><h4><strong><span>1. /speckit-specify (define what to build)</span></strong></h4><p><span>This is where we tell the agent what we&#8217;d like to build. This step excludes any technical information. It&#8217;s purely the business requirements for this feature.</span></p><p><span>Input verbatim: </span><em><span>&#8220;Add user-selectable sorting (date, name, distance) and search to the Favorites list.&#8221;</span></em><span> Spec Kit generated a feature slug (001-favorites-sort-search), scaffolded a directory under specs/, and dropped a filled-out spec.md in it three prioritized user stories (P1 sort, P2 search, plus edge cases), functional requirements FR-001 through FR-010, and measurable success criteria (SC-001: </span><em><span>&#8220;20 favorites, target route found by name in under 5 seconds&#8221;</span></em><span>).</span></p><p><span>The template forced a discipline I keep skipping when I write my own tickets: independently testable user stories, prioritized. If I had just typed </span><em><span>&#8220;add sort and search&#8221;</span></em><span> into a normal ticket, sort and search would have shipped bundled. Here they became User Story 1 (P1) and User Story 2 (P2), each with its own acceptance scenarios, either shippable alone.</span></p><h4><strong><span>2. /speckit-clarify (optional follow up questions)</span></strong></h4><p><span>This is the optional action where the agent can look at our requirements and ask any follow-up questions. </span></p><p><span>Eight questions surfaced. A few of them:</span></p><ul><li><p><span>What&#8217;s the default sort? (Answer: dateSaved descending, matches current behavior, so users who never touch the menu see no change.)</span></p></li><li><p><span>Does search match name only or also formatted stats like &#8220;3.2 km&#8221;? (Name only. Matching localized formatted text would be fragile across locales and units.)</span></p></li><li><p><span>How do we sort favorites whose name is empty? (Sort them last when the user picks &#8220;Name&#8221;, with savedDate as tie-breaker.)</span></p></li><li><p><span>Persist sort selection? Persist search text? (Sort yes, search no.)</span></p></li></ul><h4><strong><span>3. /speckit-plan (implementation plan)</span></strong></h4><p><span>The planning phase is really cozy. This will create a plan, the data models, the service interfaces and even perform research.<br>The current view had a private computed property doing the sorting inline </span>which violates Principle I (business logic in views). The plan moved sort and filter into <em><strong>FavoriteRoutesFeature.State.filteredSortedFavorites</strong></em> a computed property on the state. The view goes back to being a thin observer.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;2ba03ddd-c96a-42fb-ad81-d8d13e55e0e1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">private var sortedFavoriteRoutes: [FavoriteRoute] {
    store.favoriteRoutes.sorted { $0.savedDate &gt; $1.savedDate }
}</code></pre></div><p><span>Notice: </span><strong><span>the plan</span></strong><span> step </span><strong><span>not</span></strong><span> a </span><strong><span>code reviewer</span></strong><span>, caught the constitution violation. That&#8217;s the whole point.</span></p><p><span>The plan ends with a five item constitution-compliance checklist, all green. If any box had been red, the plan would have been the artifact I fixed, </span><strong><span>not the code.</span></strong></p><h4><strong><span>4. /speckit-tasks (break plan into actionable steps)</span></strong></h4><p>It takes the plan and break it up into individual tasks. These are all the individual tasks like the actual code changes to implement this feature.</p><p><span>Tasks came out in three phases: </span></p><p><span>A (reducer + data model + persistence + tests), </span></p><p><span>B (view with sort menu and .searchable), </span></p><p><span>C (localization for all 15 locales). </span></p><p><span>Ordering matters: each phase leaves the project buildable and green.</span></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!UUv3!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!UUv3!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 424w, https://substackcdn.com/image/fetch/$s_!UUv3!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 848w, https://substackcdn.com/image/fetch/$s_!UUv3!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 1272w, https://substackcdn.com/image/fetch/$s_!UUv3!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!UUv3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png" width="1142" height="400" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/cb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:400,&quot;width&quot;:1142,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:85984,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/206353670?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!UUv3!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 424w, https://substackcdn.com/image/fetch/$s_!UUv3!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 848w, https://substackcdn.com/image/fetch/$s_!UUv3!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 1272w, https://substackcdn.com/image/fetch/$s_!UUv3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb3d5d8b-6219-4144-af3a-79f40ca0a437_1142x400.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><span>/speckit-analyze then produced an alignment matrix mapping every FR to a task, a test and a status. That table is now the artifact I&#8217;d hand to a reviewer instead of &#8220;trust me, it&#8217;s covered.&#8221; </span>&#128556;</p><h4><strong>5. /speckit-analyze (optional validate docs)</strong></h4><p>Then we have another optional step called analyze. It will simply look at everything that&#8217;s been produced before it to make sure that the documentation is complete and we&#8217;ve covered everything.</p><h4><strong><span>6. /speckit-implement (execute tasks and test)</span></strong></h4><p><span>Then finally, we have the implement step. This is where we tell the coding agent, hey, we have everything we need. Go ahead and implement all of these tasks. We should be able to test our changes in the system and ask the agent to make any adjustments. </span></p><p><span>Concrete numbers from the run:</span></p><ul><li><p><span>Files touched: </span><em><strong><span>FavoritesSortOption.swift (new), PersistenceClient.swift, FavoriteRoutesFeature.swift, FavoriteRoutesFeatureTests.swift, FavoriteRoutesView.swift, UIConstants.swift, Shared/L10n.swift, Localizable.xcstrings.</span></strong></em></p></li><li><p><span>Localization: 5 new keys &#215; 15 locales = 75 xcstrings entries, zero missed locales. This is Principle II operationalized. The AI knew the constitution required 15 locales, so it produced 15.</span></p></li><li><p><span>Tests: 12 in FavoriteRoutesFeatureTests before, 21 after (+9 new). All 88 tests across 10 suites pass.</span></p></li><li><p><span>Compile-break as a feature. Widening PersistenceClient with two new closures broke every test that constructed one. That&#8217;s not a bug, it&#8217;s the compiler surfacing the exact migration the plan had already enumerated. One edit fixed all callsites. Compiler-enforced migrations are a friend of SDD, they turn </span><em><span>&#8220;did we get every callsite?&#8221;</span></em><span> from a grep into a build error.</span></p></li></ul><p><span>The view diff is the tweetable moment:</span></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;bdd6a102-2511-4187-b661-66b1f26c17f0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// Before
private var sortedFavoriteRoutes: [FavoriteRoute] {
    store.favoriteRoutes.sorted { $0.savedDate &gt; $1.savedDate }
}
// ...
ForEach(sortedFavoriteRoutes) { favorite in ... }

// After
ForEach(store.filteredSortedFavorites) { favorite in ... }</code></pre></div><p><span>That&#8217;s what &#8220;thin view, fat reducer&#8221; looks like when it&#8217;s enforced by a </span><strong><span>plan</span></strong><span> and not by a reviewer&#8217;s comment.</span></p><h3>What Spec Kit does not solve?</h3><ul><li><p><span>Overhead on tiny changes. Running </span><em><strong><span>/speckit-specify</span></strong></em><span> to add a SwiftUI </span><em><strong><span>#Preview</span></strong></em><span> block or fix a typo is absurd. Reserve SDD for changes that touch more than one file or introduce user-visible behavior. Below that bar, just edit the code :)</span></p></li><li><p><span>The constitution is only as good as your enforcement. Nothing in Spec Kit runs tests, greps for magic numbers, or diffs your xcstrings for missing locales. The constitution is a single source of truth the AI reads; enforcement is still your CI, your pre-commit, your reviewer.</span></p></li><li><p><span>Xcode integration is via file system. Spec Kit never touches project.pbxproj. On Xcode 16+ with synchronized groups this is fine. On legacy projects, add friction to your estimate.</span></p></li><li><p><span>Localization can&#8217;t be verified without inspecting the catalog. </span><em><strong><span>String(localized:)</span></strong></em><span> alone doesn&#8217;t guarantee entries exist in </span><em><strong><span>Localizable.xcstrings</span></strong></em><span>. I ran a one-liner to prove it after each phase:</span></p></li></ul><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;6bed6f20-09b7-46d8-af62-5e1ea17201d4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">python3 -c "import json; d=json.load(open('.../Localizable.xcstrings')); \
[print(k, len(d['strings'][k]['localizations'])) \
 for k in ['Sort','Name','Date saved','Search favorites','No matching favorites']]"</code></pre></div><div><hr></div><p>SDD is a framework for making AI collaboration reviewable: every step produces an artifact you&#8217;d be willing to defend in code review and every artifact is validated against a constitution you wrote deliberately. If you&#8217;ve been using Cursor like me as a smarter autocomplete, this is the next promotion.</p><p><span>Two pieces I leaned on heavily and would recommend as further reading: Mad Devs on SDD in iOS (</span><a href="https://maddevs.io/writeups/spec-driven-development-in-ios/"><span>https://maddevs.io/writeups/spec-driven-development-in-ios/</span></a><span>) for the process wisdom, and Stackademic&#8217;s &#8220;Death of Vibe Coding&#8221; (</span><a href="https://blog.stackademic.com/kiro-ai-for-ios-how-agentic-development-and-spec-driven-swiftui-replace-prompt-coding-091bf760bdbc"><span>https://blog.stackademic.com/kiro-ai-for-ios-how-agentic-development-and-spec-driven-swiftui-replace-prompt-coding-091bf760bdbc</span></a><span>) for the mindset shift.</span></p><p><span>Next I want to write about adding </span><em><strong><span>/speckit-checklist</span></strong></em><span> to CI so the constitution enforces itself in PR review, instead of quietly hoping I&#8217;ll remember. If the constitution is the point, that&#8217;s the natural next step.</span></p><p><strong>Sources:</strong></p><ul><li><p>Other similar options to Spec Kit; <a href="https://specs.md">https://specs.md</a>, <a href="https://github.com/bmad-code-org/bmad-method">https://github.com/bmad-code-org/bmad-method</a></p></li><li><p>Mad Devs on SDD in iOS - (<a href="https://maddevs.io/writeups/spec-driven-development-in-ios/">https://maddevs.io/writeups/spec-driven-development-in-ios/</a>)</p></li><li><p>Death of Vibe Coding - (<a href="https://blog.stackademic.com/kiro-ai-for-ios-how-agentic-development-and-spec-driven-swiftui-replace-prompt-coding-091bf760bdbc">https://blog.stackademic.com/kiro-ai-for-ios-how-agentic-development-and-spec-driven-swiftui-replace-prompt-coding-091bf760bdbc</a>)</p></li></ul><p></p>]]></content:encoded></item><item><title><![CDATA[Splitting Large SwiftUI Views in the Apple's way]]></title><description><![CDATA[Extract Subviews, Not Computed Properties (and Where @ViewBuilder Actually Fits)]]></description><link>https://emredegirmenci.substack.com/p/splitting-large-swiftui-views-in</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/splitting-large-swiftui-views-in</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Tue, 07 Jul 2026 11:40:26 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!wOfj!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fpbs.substack.com%2Fmedia%2FHMDvSVCbAAAfJY1.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Apple recently added this to their Xcode 27 coding skills guidance, and it started a good conversation on X. Vincent <strong><a href="https://twitter.com/v_pradeilles">shared it</a></strong> and Shannon asked a fair question: <strong>if you split a large view, does </strong><em>@ViewBuilder</em><strong> help with identity?</strong></p><div class="twitter-embed" data-attrs="{&quot;url&quot;:&quot;https://x.com/v_pradeilles/status/2071918929975685351?s=20&quot;,&quot;full_text&quot;:&quot;Don't use computed properties to split a large View &#128581;&#127997;&#8205;&#9792;&#65039;&#128581;&#127995;&#8205;&#9794;&#65039;\n\nThis an official bad practice written by Apple in Xcode 27 coding skills &quot;,&quot;username&quot;:&quot;v_pradeilles&quot;,&quot;name&quot;:&quot;Vincent Pradeilles&quot;,&quot;profile_image_url&quot;:&quot;https://pbs.substack.com/profile_images/1587390960375316481/hFEl2TXy_normal.jpg&quot;,&quot;date&quot;:&quot;2026-06-30T11:29:01.000Z&quot;,&quot;photos&quot;:[{&quot;img_url&quot;:&quot;https://pbs.substack.com/media/HMDvSVCbAAAfJY1.jpg&quot;,&quot;link_url&quot;:&quot;https://t.co/6H7YawLfCI&quot;}],&quot;quoted_tweet&quot;:{},&quot;reply_count&quot;:9,&quot;retweet_count&quot;:22,&quot;like_count&quot;:210,&quot;impression_count&quot;:9935,&quot;expanded_url&quot;:null,&quot;video_url&quot;:null,&quot;video_preview_media_key&quot;:null,&quot;belowTheFold&quot;:false}" data-component-name="Twitter2ToDOM"></div><p>My reply was:</p><blockquote><p>@ViewBuilder gives you clean structural identity for if/switch branches but it doesn&#8217;t create a new invalidation boundary. Same struct, same boundary. Separate View types are still the win for performance imho :)</p></blockquote><p>I applied this in my app <strong><a href="https://apple.co/4mz7vev">Walk Mate</a></strong> while refactoring a few heavy screens. This post is about what I changed, why it matters and where <em><strong>@ViewBuilder</strong></em> still has a place.</p><h3>The Core Idea</h3><p>When state changes, SwiftUI re-runs the <em><strong>body </strong></em>of the smallest enclosing <strong>view type </strong>that depends on that state. Everything inside that body gets evaluated again: conditionals, modifier chains, string interpolation, helper functions, computed properties. Same invalidation boundary. Same cost.</p><p>A separate <em><strong>View </strong></em>struct with narrow inputs gets its <strong>own boundary</strong>. <strong>SwiftUI can skip its body when only its inputs are unchanged.</strong> That&#8217;s the difference &#8220;skip for readability&#8221; and &#8220;split for performance&#8221;.</p><h3>Extract Subviews, Not Computed Properties</h3><h4>What I had before</h4><p>My <em><strong>ContentView </strong></em>was already split into computed properties:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;798d74b2-da68-4bec-b4ff-184bcbb01310&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct ContentView: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt; // TCA thing :)
    @Namespace private var mapScope

    var body: some View {
        configuredContent
    }

    private var configuredContent: some View {
        lifecycleContent
            .alert($store.scope(state: \.alert, action: \.alert))
            .sheet(item: $store.scope(state: \.settingsSheet, action: \.settingsSheet)) { ... }
            // many more modifiers...
    }

    private var lifecycleContent: some View {
        mainContent
            .onAppear { store.send(.onAppear) }
    }

    private var mainContent: some View {
        ZStack(alignment: .bottom) {
            MapContainerView(store: store, mapScope: mapScope)
            // map overlays, loading, bottom controls, medal toast, adventure completed...
        }
    }
}</code></pre></div><p>It reads better than one giant <em><strong>body</strong></em> but every store update still re-evaluates all of those computed properties together.</p><p>Same story in <em><strong>AdventureCompletedView</strong></em>. The completion card lived inline inside <em><strong>body:</strong></em></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;1dd4d384-2395-4bc3-aa70-6aecaa04151e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">var body: some View {
    if store.walkSession.showAdventureCompleted,
       let selectedRoute = store.routeGeneration.selectedRoute {
        ZStack {
            Color.black.opacity(0.5).ignoresSafeArea()

            ScrollView {
                VStack(spacing: 0) {
                    // trophy, title, stats HStack, achievements, buttons...
                    StatCardView(
                        icon: &#8220;medal.fill&#8221;,
                        value: &#8220;\(selectedRoute.medals.filter { $0.isCollected }.count)/\(selectedRoute.medals.count)&#8221;,
                        label: String(localized: &#8220;Medals Collected&#8221;),
                        iconColor: .yellow
                    )
                    // more cards...
                }
            }
        }
    }
}</code></pre></div><p>Any small animation state change (<em><strong>cardScale</strong></em>, <em><strong>cardOpacity</strong></em>, <em><strong>confetti</strong></em>) could pull the whole card tree back through evaluation &#128260;.</p><h4>What I changed</h4><p>I replaced computed properties with private view structs, each with a focused job:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;38ef18b5-3d35-418c-8e0d-2d37133fdc6d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct ContentView: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt;
    @Namespace private var mapScope

    var body: some View {
        ContentConfiguredView(store: store, mapScope: mapScope)
    }
}

private struct ContentConfiguredView: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt;
    let mapScope: Namespace.ID

    var body: some View {
        ContentLifecycleView(store: store, mapScope: mapScope)
            .alert($store.scope(state: \.alert, action: \.alert))
            .sheet(item: $store.scope(state: \.settingsSheet, action: \.settingsSheet)) { settingsStore in
                SettingsView(store: settingsStore, contentStore: store)
            }
            // sheets only here
    }
}

private struct ContentLifecycleView: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt;
    let mapScope: Namespace.ID

    var body: some View {
        ContentMainView(store: store, mapScope: mapScope)
    }
}

private struct ContentMainView: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt;
    let mapScope: Namespace.ID

    var body: some View {
        ZStack(alignment: .bottom) {
            ContentMapLayer(store: store, mapScope: mapScope)

            if store.routeGeneration.isGeneratingRoutes {
                LoadingOverlay()
            }

            ContentForegroundStack(store: store)

            ContentMedalToastOverlay(
                medal: store.walkSession.lastCollectedMedal,
                isShowing: store.walkSession.showMedalCollectionToast,
                onDismiss: { store.send(.walkSession(.dismissMedalCollectionToast)) }
            )

            if store.walkSession.showAdventureCompleted {
                AdventureCompletedView(store: store)
            }
        }
    }
}</code></pre></div><p>Then I split the map and foreground UI too:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;fe4ebfa9-c029-4926-b358-38ad2619a4cb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">private struct ContentMapLayer: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt;
    let mapScope: Namespace.ID

    var body: some View {
        MapContainerView(store: store, mapScope: mapScope)
            .overlay(alignment: .trailing) {
                if !store.drawRoute.isDrawingRoute
                    &amp;&amp; !store.avoidance.isMarkingAvoidanceMode
                    &amp;&amp; !store.avoidance.isEditingAvoidedLocations {
                    MapControlsView(store: store, mapScope: mapScope)
                }
            }
            .safeAreaInset(edge: .top) {
                if !store.drawRoute.isDrawingRoute
                    &amp;&amp; !store.avoidance.isMarkingAvoidanceMode
                    &amp;&amp; !store.avoidance.isEditingAvoidedLocations {
                    AdventureStatsView(store: store)
                }
            }
    }
}

private struct ContentForegroundStack: View {
    @Bindable var store: StoreOf&lt;ContentFeature&gt;

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            if store.avoidance.isMarkingAvoidanceMode {
                ContentAvoidanceBanner(
                    showsTapToMarkBanner: store.avoidance.temporarySelectedSegment == nil
                        &amp;&amp; store.avoidance.avoidedSegments.isEmpty
                )
            }
            Spacer()
            if store.drawRoute.isDrawingRoute {
                DrawRouteOverlayView(store: store)
            } else if !store.avoidance.isMarkingAvoidanceMode
                        &amp;&amp; !store.avoidance.isEditingAvoidedLocations {
                BottomControls(
                    routes: store.routeGeneration.routes,
                    isGeneratingRoutes: store.routeGeneration.isGeneratingRoutes,
                    store: store,
                    selectedRoute: store.routeGeneration.selectedRoute
                )
            }
        }
    }
}</code></pre></div><p>The parent <em><strong>ContentView</strong></em> body is now one line. Each layer owns its own invalidation boundary.</p><p>I extracted the card into <em><strong>AdventureCompletionCard</strong></em> and passed only what it needs:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;c78e0314-7c3e-40f5-a2c5-4695f0800108&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">AdventureCompletionCard(
    selectedRoute: selectedRoute,
    distanceWalked: store.walkSession.distanceWalked,
    distanceUnit: store.distanceUnit,
    newlyUnlockedMilestones: store.walkSession.newlyUnlockedMilestones,
    onWalkAgain: { store.send(.walkSession(.walkAgainTapped)) }
)</code></pre></div><p>And the card struct:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;6b1b9339-51e6-44ee-bdf5-35dbabd60f97&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">private struct AdventureCompletionCard: View {
    let selectedRoute: Route
    let distanceWalked: Double
    let distanceUnit: DistanceUnit
    let newlyUnlockedMilestones: [Milestone]
    let onWalkAgain: () -&gt; Void

    var body: some View {
        VStack(spacing: 0) {
            // trophy, title, stats, achievements, buttons
        }
        .compatibleGlassEffect(cornerRadius: 24)
    }
          ...
}</code></pre></div><p>I also split button styling into <em><strong>AdventureCompletedActionButtons, AdventureCompletedActionButton</strong></em> and <em><strong>AdventureCompletedButtonBackground</strong></em>.</p><p>Now animation state (<em><strong>cardScale, showConfetti</strong></em>) lives in <em><strong>AdventureCompletedView</strong></em>, while the heavy card content lives in a separate type with stable inputs.</p><h4>What this gives you</h4><ul><li><p>Better diffing: SwiftUI compares struct inputs, not one giant parent body.</p></li><li><p>Less wasted work on unrelated state changes.</p></li><li><p>Easier previews: you can preview <em><strong>AdventureCompletionCard</strong></em> with sample data.</p></li><li><p>Clearer ownership: each file section has one responsibility. Solid? :D </p></li></ul><h3>@ViewBuilder (and What It Does NOT Do)</h3><p>This is where the X thread gets interesting. <em><strong>@ViewBuilder </strong></em>is useful but it solves a different problem than subview extraction.</p><h4>What @ViewBuilder is good at</h4><p><em><strong>@ViewBuilder</strong></em> helps when you need conditional view structure inside one body:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;13baafff-f8ea-4900-b50d-6f904af642b0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@ViewBuilder
private var conditionalView: some View {
    if isExpanded {
        VStack {
            Text(&#8221;Expanded View&#8221;)
            Image(systemName: &#8220;star&#8221;)
        }
    } else {
        Text(&#8221;Collapsed View&#8221;)
    }
}</code></pre></div><p>It gives you clean syntax for <em><strong>if/switch</strong></em> branches and structural identity for those branches.</p><p>Apple&#8217;s guidance also says: use it for <strong>small</strong>, <strong>simple</strong> <strong>sections</strong> that <strong>do not</strong> need their <strong>own invalidation boundary.</strong></p><h4>What @ViewBuilder does not do</h4><p>It does <strong>not</strong> create a new invalidation boundary. If you put your whole complex section in a <em><strong>@ViewBuilder</strong></em> function inside the parent struct that function still runs whenever the parent body runs &#128260;.</p><p>So this is still one boundary. Tapping the button re-evaluates <em><strong>complexSection() </strong></em>every time.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;ff4b760b-ed77-4586-8889-91d46f3b52b2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">struct ParentView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Button(&#8221;Tap: \(count)&#8221;) { count += 1 }
            complexSection()
        }
    }

    @ViewBuilder
    func complexSection() -&gt; some View {
        ForEach(0..&lt;100) { i in
            Text(&#8221;Item \(i)&#8221;)
        }
    }
}</code></pre></div><h4>The objc.io lesson: conditional branches can break identity</h4><p>Chris Eidhof wrote a great post on this: <strong><a href="https://www.objc.io/blog/2021/08/24/conditional-view-modifiers/">Why Conditional View Modifiers are a Bad Idea</a></strong>.</p><p>The key point: when you branch with <em><strong>if/else</strong></em>, SwiftUI often sees different view types in each branch <em><strong>(_ConditionalContent&lt;...&gt;)</strong></em>. That can:</p><ul><li><p>break smooth animations (fade transition instead of interpolation)</p></li><li><p>reset <em><strong>@State / @StateObject</strong></em> when the branch flips</p></li></ul><p>Example from that post:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;3ee20712-e705-410b-b1d6-311b453c9275&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Rectangle()
    .applyIf(condition: myState, transform: { $0.frame(width: 100) })</code></pre></div><p>vs</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;878b25ae-948a-41d8-bd56-71f8c5c68a12&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Rectangle()
    .frame(width: myState ? 100 : nil)</code></pre></div><p>The second one keeps the same view identity and animates correctly.</p><p>Same idea applies to <em><strong>@ViewBuilder</strong></em> conditionals inside one parent: they are fine for structure but they are not a free performance win and they can still hurt identity if you use them like a conditional modifier wrapper.</p><h4>Where I still use small extracted structs instead of @ViewBuilder helpers</h4><p>In Walk Mate, I extracted <em><strong>ContentAvoidanceBanner</strong></em> instead of keeping nested <em><strong>if</strong></em> blocks inline:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;2b56165b-bd32-4785-be26-4580f4427754&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">private struct ContentAvoidanceBanner: View {
    let showsTapToMarkBanner: Bool

    var body: some View {
        VStack(spacing: 0) {
            if showsTapToMarkBanner {
                TapToMarkBanner()
                    .padding(.horizontal, AppConstants.UI.largeSpacing)
                    .padding(.trailing, AppConstants.UI.bannerTrailingPadding)
                    .padding(.top, 80)
            }
        }
    }
}</code></pre></div><p>This is a small view but it has a clear input (<em><strong>showsTapToMarkBanner</strong></em>) and its own boundary. The parent only passes a <em><strong>Bool</strong></em>, not the whole banner layout logic.</p><p>That is the pattern I follow now:</p><ul><li><p><em><strong>@ViewBuilder</strong></em> for tiny local branching when extraction would be noisy</p></li><li><p>separate <em><strong>View</strong></em> structs for anything stateful expensive or reused</p></li></ul><h3>Practical Checklist (What I Use in Code Reviews Now)</h3><ol><li><p>If a screen has <em><strong>private var section:</strong></em><code> </code><em><strong>some View</strong></em> and that section depends on changing state, extract a struct.</p></li><li><p>Pass narrow inputs (<em><strong>Route, Double, [Milestone], Bool</strong></em>) instead of the whole store when possible.</p></li><li><p>Do not use computed properties as a fake performance split &#128514;</p></li><li><p>Do not use custom <em><strong>.applyIf</strong></em> style helpers for modifiers.</p></li><li><p>Prefer <em><strong>modifier(value ? a : b)</strong></em> over <em><strong>if</strong></em> branches for the same view.</p></li><li><p>Keep <em><strong>@ViewBuilder</strong></em> for small structural branching not as a replacement for subviews.</p></li></ol><div><hr></div><p>Splitting a large SwiftUI view is not just a style preference anymore. Apple is calling it out directly and in a map-heavy app like <strong><a href="https://apple.co/4mz7vev">Walk Mate</a></strong> it makes a real difference.</p><p><em><strong>@ViewBuilder</strong></em> is still useful. It gives you clean conditional structure. But if your goal is fewer body re-evaluations, separate <em><strong>View</strong></em> types with narrow inputs are the win.</p><p>If you are refactoring an old screen, start with the noisiest part first: map overlays, stats sections, completion cards, sheet containers. That is where I saw the biggest readability and performance payoff.</p><p><strong><span>Sources</span></strong></p><ul><li><p>Apple view structure guidance (Xcode 27 coding skills) </p></li><li><p>swiftui-expert-skill/references/view-structure.md</p></li><li><p><strong><a href="https://www.objc.io/blog/2021/08/24/conditional-view-modifiers/">Why Conditional View Modifiers are a Bad Idea</a></strong> by Chris Eidhof (objc.io)</p></li><li><p><a href="https://www.avanderlee.com/ai-development/using-xcode-27s-agent-skills-in-claude-codex-and-cursor/">https://www.avanderlee.com/ai-development/using-xcode-27s-agent-skills-in-claude-codex-and-cursor/</a></p><p></p></li></ul>]]></content:encoded></item><item><title><![CDATA[Why My SwiftUI Map Bounced in Heading-Follow Mode?]]></title><description><![CDATA[SwiftUI Map Body Redraws]]></description><link>https://emredegirmenci.substack.com/p/why-my-swiftui-map-bounced-in-heading</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/why-my-swiftui-map-bounced-in-heading</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Mon, 29 Jun 2026 13:36:34 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!VQ8B!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!VQ8B!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!VQ8B!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 424w, https://substackcdn.com/image/fetch/$s_!VQ8B!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 848w, https://substackcdn.com/image/fetch/$s_!VQ8B!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 1272w, https://substackcdn.com/image/fetch/$s_!VQ8B!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!VQ8B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png" width="256" height="256" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/dc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:500,&quot;resizeWidth&quot;:256,&quot;bytes&quot;:121220,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/203844371?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!VQ8B!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 424w, https://substackcdn.com/image/fetch/$s_!VQ8B!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 848w, https://substackcdn.com/image/fetch/$s_!VQ8B!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 1272w, https://substackcdn.com/image/fetch/$s_!VQ8B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdc40931e-80c4-43d3-afc8-5d84427d4695_500x500.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>As you know when you double-tapped the user location button in an iOS app, it follows your heading. When I do that I hit a bug in <a href="https://apple.co/4mz7vev">Walk Mate</a> which was architected in <strong>The Composable Architecture,</strong> my daily route generator app . The blue user location button jumped, and bounced with view redraws in SwiftUI. <br><br>The weird part is the same map setup worked fine in my other app, <a href="https://apple.co/3NWejUz">EV Charge Stations Map</a> written in <strong>MVVM</strong>. Same <em><strong>Map</strong></em>, same <em><strong>MapUserLocationButton</strong></em>, same double-tap for heading-follow. One app broken, one app fine.<br><br>In this post I&#8217;ll explain <strong>why</strong> it happens and show to fix in the <strong>pure TCA way</strong>.</p><h3>What is heading-follow mode?</h3><p>When you tap the location button once, <strong>MapKit</strong> enters <strong><span>follow location</span></strong> mode. The map keeps your <strong>blue dot centered</strong>.</p><p>When you tap it <strong><span>twice</span></strong>, it enters <strong><span>heading-follow</span></strong> mode. A small blue cone appears in front of the dot. As you rotate your phone left or right, the map rotates with you.</p><p>That&#8217;s when my bug showed up. At certain zoom levels, the map kept <strong><span>jumping and bouncing</span></strong> while I rotated. Sometimes the heading cone flashed for a moment and then <strong><span>disappeared</span></strong> as if heading mode turned off by itself.</p><div class="native-video-embed" data-component-name="VideoPlaceholder" data-attrs="{&quot;mediaUploadId&quot;:&quot;a38dd7f1-8c67-4da9-9fec-5ed3eb94d875&quot;,&quot;duration&quot;:null}"></div><h3>The setup (WalkMate)</h3><p>My map lived inside TCA. The camera position was stored in <em><strong>ContentFeature.State</strong></em>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;8e510748-99b2-4256-b512-819a0c649bf5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">var cameraPosition: MapCameraPosition = .region(...)</code></pre></div><p><span>And the </span><em><strong>Map</strong></em><span> used a </span><strong><span>hand-rolled </span></strong>&#129755; <strong><span>binding</span></strong><span> that sent an action on every write:</span></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;0edd18b7-b80f-435e-9664-283528ca1304&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Map(
    position: Binding(
        get: { store.cameraPosition },
        set: { store.send(.updateCameraPosition($0)) }
    ),
    scope: mapScope
) {
    // route polyline, medals, UserAnnotation(), etc.
}</code></pre></div><p>On paper this looks correct for TCA. State in the store, view reads from store, user interaction writes back through actions. That&#8217;s the pattern we use everywhere else.</p><p>But MapKit in heading-follow mode doesn&#8217;t behave like a text field or a slider :)</p><h3>Why the bug happens?</h3><p>In heading-follow mode, MapKit runs a <strong><span>continuous camera animation</span></strong>. Every time your device heading changes even a little, MapKit updates the camera (many times per second).</p><p>With our hand-rolled &#129755; binding, <strong><span>every single camera frame</span></strong> did this:</p><ol><li><p>MapKit writes a new camera position &#8594; <em><strong>set</strong></em> fires</p></li><li><p><em><strong>store.send(.updateCameraPosition($0))</strong></em> runs</p></li><li><p>Reducer sets <em><strong>state.cameraPosition</strong></em></p></li><li><p>SwiftUI re-renders the <em><strong>Map</strong></em></p></li><li><p><em><strong>get</strong></em> returns the value from state</p></li><li><p>MapKit receives it back as an <em><strong><span>external</span></strong></em> camera command</p></li></ol><p><strong><span>The problem:</span></strong> In step 6, we send the camera position back to MapKit, but that value is already old. MapKit is still rotating the map for heading-follow and at the same time it gets a new position from the app. So the map tries to follow two things at once. That&#8217;s why we see the jump, the shake and why heading mode often turns off. The blue cone goes away.</p><p>When you zoom in, this looks even worse. Small camera mistakes are easier to see when you&#8217;re close to the map.</p><p>There was a second problem too. On location updates, the reducer sometimes did:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;26d54662-3919-4187-8191-511a391ac2a6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">state.cameraPosition = .region(...)</code></pre></div><p><span>That runs on GPS ticks. So right after you entered heading-follow, the next location update could </span><strong><span>overwrite</span></strong><span> MapKit&#8217;s follow pointer with a fixed region. Heading cone flashes &#8594; gone.</span></p><h3>Why <a href="https://apple.co/3NWejUz">EV Charge Stations Map</a> didn&#8217;t have this bug?</h3><p><span>I compared with my other app. The difference wasn&#8217;t the button. It was </span><strong><span>who owns the camera during animation</span></strong><span>.</span></p><p><span>EV app:</span></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;4a699a3a-154d-4da1-82e7-efde4c5d6cd4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Map(position: $mapViewModel.cameraPosition, scope: mapScope)</code></pre></div><p>Direct binding to <em><strong>@Published var cameraPosition</strong></em>. <em><strong>onMapCameraChange</strong></em> only updated <em><strong>currentRegion</strong></em> for clustering <strong><span>not</span></strong> the camera position on <strong>every frame</strong>.<br>Location updates didn&#8217;t constantly reset <em><strong>cameraPosition</strong></em> to a fixed <em><strong>.region(...)</strong></em>.</p><h3>Fix - Pure TCA way (BindingReducer + $store.cameraPosition)</h3><p><strong><a href="https://pointfreeco.github.io/swift-composable-architecture/1.8.0/documentation/composablearchitecture/bindings/">TCA bindings</a></strong><span> exist partly to remove boilerplate like this:</span></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;6c6d2e63-2ce1-48c4-bb1b-2b1baf225966&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">case updateCameraPosition(MapCameraPosition)

case let .updateCameraPosition(position):
    state.cameraPosition = position
    return .none</code></pre></div><p>I replaced the hand-rolled &#129755; binding with first-class TCA bindings. We can eliminate boilerplate using <em><strong>BindableAction</strong></em> and <em><strong>BindingReducer</strong></em>.</p><p><strong><span>Reducer:</span></strong></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;4ff4de60-4714-474c-b7f1-6d6067e792d9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">enum Action: BindableAction, Equatable {
    case binding(BindingAction&lt;State&gt;)
    // ...
}

var body: some ReducerOf&lt;Self&gt; {
    BindingReducer()
    contentWithMapSheets
}</code></pre></div><p><strong>View:</strong></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;391cd0d3-b589-41a3-8497-e78591f14742&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@Bindable var store: StoreOf&lt;ContentFeature&gt;

Map(position: $store.cameraPosition, scope: mapScope) {
    // ...
}</code></pre></div><p>Now Map-driven updates go through <em><strong>$store.cameraPosition</strong></em> &#8594; <em><strong>BindingReducer()</strong></em> &#8594; <em><strong>state.cameraPosition</strong></em>. No custom <em><strong>updateCameraPosition</strong></em> action. No <em><strong>send</strong></em> on every frame through a closure we wrote by hand.</p><p>We also guarded location-driven camera resets:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;d33ae679-e5b0-47c9-a57f-982788d70f7e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">let shouldUpdateCamera = !state.avoidance.isEditingAvoidedLocations
    &amp;&amp; !state.cameraPosition.followsUserLocation
    &amp;&amp; (/* other conditions */)</code></pre></div><p>So GPS updates don&#8217;t pull the map out of heading mode.</p><p><strong>What stays in the reducer on purpose:</strong> programmatic moves like framing a route, opening a sheet or reapplying the camera after a sheet animation (<em><strong>forceCameraPosition</strong></em>). Those are business logic, not UI bindings. <em><strong>BindingReducer()</strong></em> doesn&#8217;t replace them.</p><div class="native-video-embed" data-component-name="VideoPlaceholder" data-attrs="{&quot;mediaUploadId&quot;:&quot;e9aabaeb-3912-4771-87a4-7f2200dfdef8&quot;,&quot;duration&quot;:null}"></div><div><hr></div><p><strong>My takeaway:</strong> the bug was <strong><span>treating MapKit&#8217;s continuous camera stream like a simple two-way form field</span></strong>. Sliders and text fields want every write in the store. Heading-follow wants MapKit to drive until you programmatically take over.</p><p><strong><span>Sources:</span></strong></p><ul><li><p><strong><a href="https://pointfreeco.github.io/swift-composable-architecture/1.8.0/documentation/composablearchitecture/bindings/">TCA Bindings documentation</a></strong></p></li><li><p><strong><a href="https://developer.apple.com/documentation/mapkit/mapcameraposition">MapCameraPosition - followsUserLocation / followsUserHeading</a></strong></p></li></ul><p></p>]]></content:encoded></item><item><title><![CDATA[Adopting SwiftData for a Core Data app]]></title><description><![CDATA[Last week I decided to finally move my side project, EV Charge Stations Map, from Core Data to SwiftData. Every new Apple sample, every WWDC session, every blog post I bookmarked and never read&#8230; they all pointed to SwiftData. And I got tired of maintaining]]></description><link>https://emredegirmenci.substack.com/p/adopting-swiftdata-for-a-core-data</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/adopting-swiftdata-for-a-core-data</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sun, 21 Jun 2026 06:54:10 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!o1-v!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Last week I decided to finally move my side project, <strong><a href="https://apple.co/3NWejUz"><span>EV Charge Stations Map</span></a></strong>, from <strong>Core Data</strong> to <strong>SwiftData</strong>. Every new Apple sample, every WWDC session, every blog post I bookmarked and never read&#8230; they all pointed to SwiftData. And I got tired of maintaining <em><strong>.xcdatamodeld</strong></em> plus generated <em><strong>+CoreDataClass</strong></em> files plus <em><strong>NSFetchRequest</strong></em> boilerplate for a pretty small data layer.</p><p>My app stores three things locally: favorited charging stations, their connector details, and saved route planner inputs. No CloudKit sync. No background contexts. Just one <em><strong>viewContext</strong></em> passed around SwiftUI views. I thought &#8220;this should be straightforward.&#8221;</p><p>It was. Mostly. But I also hit a launch crash, one schema mistake that almost made me panic about user data. So here is what actually happened.</p><h3>What I Had Before</h3><p>Classic Core Data setup. <em><strong>PersistenceController</strong></em> with <em><strong>NSPersistentContainer</strong></em>, entities in <em><strong>.xcdatamodeld</strong></em>, and a bunch of <em><strong>+CoreDataProperties.swift</strong></em> files.</p><p>The stack looked like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;e8b92452-4b37-4387-a445-062d0f5ae1bc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">import CoreData

final class PersistenceController: ObservableObject {
    let container = NSPersistentContainer(name: "EV Charge Stations Map")

    init() {
        container.loadPersistentStores { desc, error in
            if let error = error {
                Log.log("Core Data failed to load: \(error.localizedDescription)", level: .error)
            }
        }
    }
}</code></pre></div><p>In the app entry point I injected the context everywhere:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;d4fa11da-07f8-49ca-96e0-6352aba37455&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">.environment(\.managedObjectContext, dataController.container.viewContext)</code></pre></div><p>Views used<span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span><em><strong>@FetchRequest</strong></em><span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span>and<span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span><em><strong>moc:</strong></em></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;8bd477b4-3980-4c33-8587-dae3d84277c6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@Environment(\.managedObjectContext) var moc
@FetchRequest(sortDescriptors: []) var favorites: FetchedResults&lt;ChargingPoint&gt;</code></pre></div><p>And when someone favorited a charger from the map, the code was very Core Data &#128578;</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;e8983694-9df2-45c8-8b02-75d8f308b3c6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">let newFavorite = ChargingPoint(context: moc)
newFavorite.name = chargingPointPOILocation.title
newFavorite.latitude = chargingPointPOILocation.coordinate?.latitude ?? 0.0
// ...

let connection = ChargingPointConnection(context: moc)
connection.powerkW = chargingPointConnectionType.powerkW
newFavorite.addToChargingPointConnections(connection)

try moc.save()</code></pre></div><h3>How I Thought About the Migration</h3><p>I did not want in one PR. I treated it like rewiring a house room by room while people still live in it. Same SQLite file on disk. Same entity names. Same property names. Just swap the API layer on top.</p><h3>Step 1: Replace Entities With <code>@Model</code> Classes</h3><p>I started with the simplest entity, <em><strong>RoutePlannerSelectionPersistence</strong></em>. No relationships. Just saved route fields.</p><p>Core Data had a <em><strong>.xcdatamodel</strong></em> entry plus two generated Swift files. SwiftData wants one file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;253782fd-baed-40d7-9a1b-6ec51ca7abc5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">import SwiftData

@Model
final class RoutePlannerSelectionPersistence {
    var initialSOC: Double
    var selectedTargetLocationLat: Double
    var selectedTargetLocationLon: Double
    var selectedTargetLocationName: String?
    var selectedTypecode: String?
    var selectedVehicleDetails: String?
    var yourLocationLat: Double
    var yourLocationLon: Double
    var yourLocationName: String?

    init(initialSOC: Double = 0.0, /* ... */) {
        self.initialSOC = initialSOC
        // ...
    }
}</code></pre></div><p>Then<span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span><em><strong>ChargingPointConnection</strong></em><span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span>(child), then<span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span><em><strong>ChargingPoint</strong></em><span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span>(parent with the relationship):</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;0e3d6490-a2e2-469b-bf57-dbed302e2ebc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@Model
final class ChargingPoint {
    var name: String?
    var latitude: Double
    var longitude: Double
    // ...

    @Relationship(deleteRule: .nullify, inverse: \ChargingPointConnection.chargingPoint)
    var chargingPointConnections: [ChargingPointConnection] = []

    func addConnection(_ connection: ChargingPointConnection) {
        connection.chargingPoint = self
        chargingPointConnections.append(connection)
    }
}</code></pre></div><p>One thing I learned: put <em><strong>@Relationship</strong></em> on <strong><span>one side only</span></strong> (the parent). On the child just declare <em><strong>var chargingPoint: ChargingPoint?</strong></em> without the macro. Apple&#8217;s docs say this, I ignored it at first, then went back and fixed it &#128123;</p><p>I deleted all <em><strong>+CoreDataClass</strong></em> and <em><strong>+CoreDataProperties</strong></em> files and eventually removed <em><strong>.xcdatamodeld</strong></em> from the project entirely.</p><h3>Step 2: Swap the Stack</h3><p>Old:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;8e433a81-00d8-4538-b712-64ab74a2d1e9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">NSPersistentContainer(name: "EV Charge Stations Map")</code></pre></div><p>New:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;dd0512f5-a1ac-422a-a441-e0ce143be8f4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">static let schema = Schema([
    ChargingPoint.self,
    ChargingPointConnection.self,
    RoutePlannerSelectionPersistence.self,
])

static var storeURL: URL {
    let directory = FileManager.default.urls(
        for: .applicationSupportDirectory,
        in: .userDomainMask
    ).first!
    return directory.appendingPathComponent("EV Charge Stations Map.sqlite")
}

let configuration = ModelConfiguration(
    "EV Charge Stations Map",
    schema: schema,
    url: storeURL,
    cloudKitDatabase: .none
)

modelContainer = try ModelContainer(for: schema, configurations: configuration)</code></pre></div><p>That <em><strong>storeURL</strong></em> line is the whole game if you care about existing users. Point SwiftData at the <strong><span>same</span></strong> <em><strong>.sqlite</strong></em> file Core Data already used. Same folder (<em><strong>Application Support</strong></em>), same filename. If you get this wrong, the app launches fine but looks like everyone lost their data. They did not. You just opened an empty new drawer.</p><h3>Step 3: Wire the App</h3><p>Instead of injecting <em><strong>managedObjectContext</strong></em>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;2d62873f-2e71-413a-8c96-bd6e5e6d3af7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">.modelContainer(dataController.modelContainer)</code></pre></div><p>On the<span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span><em><strong>WindowGroup</strong></em><span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span>in<span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span><em><strong>EVChargeStationsMapApp</strong></em>. That is it for the plumbing. Child views pick up <em><strong>@Environment(\.modelContext)</strong></em><span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span>automatically.</p><h3>Step 4: Update Views One by One</h3><p>This part is mostly search and replace with small brain engaged.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!o1-v!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!o1-v!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 424w, https://substackcdn.com/image/fetch/$s_!o1-v!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 848w, https://substackcdn.com/image/fetch/$s_!o1-v!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 1272w, https://substackcdn.com/image/fetch/$s_!o1-v!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!o1-v!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png" width="1448" height="488" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:488,&quot;width&quot;:1448,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:153100,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/202765272?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!o1-v!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 424w, https://substackcdn.com/image/fetch/$s_!o1-v!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 848w, https://substackcdn.com/image/fetch/$s_!o1-v!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 1272w, https://substackcdn.com/image/fetch/$s_!o1-v!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa732f8ef-f74d-48b2-86d0-e3c909ffcb21_1448x488.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><em><strong>SavedRouteListView</strong></em><span data-color="rgba(228, 228, 228, 0.92)" style="color: rgba(228, 228, 228, 0.92);"> </span>after migration:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;2ac48586-d365-4a44-a0a4-9c8215398674&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@Environment(\.modelContext) private var modelContext
@Query(sort: \RoutePlannerSelectionPersistence.yourLocationName)
private var savedRoutes: [RoutePlannerSelectionPersistence]</code></pre></div><p><em><strong>ItemInfoView</strong></em> for favorites:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;9e05222d-ee52-4378-bf9f-509f8c7163b3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@Environment(\.modelContext) private var modelContext
@Query(sort: \ChargingPoint.id) private var favorites: [ChargingPoint]

func toggleFavoriteForPOIData() {
    if let existingFavorite = favorites.first(where: { $0.matches(chargingPointPOILocation) }) {
        modelContext.delete(existingFavorite)
    } else {
        let newFavorite = ChargingPoint.favorite(from: chargingPointPOILocation)
        modelContext.insert(newFavorite)
    }
    try modelContext.save()
}</code></pre></div><p>I pulled the &#8220;create favorite from map data&#8221; logic into a helper on <em><strong>ChargingPoint</strong></em> so I was not copy pasting 20 lines in every view. Small thing, saved my sanity.</p><p>View models that cannot use <em><strong>@Query</strong></em> (because <em><strong>@Query</strong></em> only works inside SwiftUI views) still fetch manually:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;a29d5d04-2ecd-41c4-918f-24157fa8e83c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">favorites = try modelContext.fetch(ChargingPoint.favoritesFetchDescriptor())</code></pre></div><h3>The Crash That Almost Ruined My Evening</h3><p>First build after migration. App launches. Instant crash. &#128556;</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;528f21cd-ada7-4c20-83f1-f00d3da8f5ec&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">Fatal error: SwiftData container failed to load: SwiftDataError(...loadIssueModelContainer...)</code></pre></div><p>The cause was embarrassing. During migration I added a new property <em><strong>id: UUID</strong></em> on <em><strong>RoutePlannerSelectionPersistence</strong></em>. Felt like a good idea. Clean identity for lists, <em><strong>Identifiable</strong></em>, future proofing, all that.</p><p>But that column <strong><span>never existed</span></strong> in the old Core Data model. SwiftData tried to open the existing SQLite file, saw a schema it could not reconcile, and gave up.</p><p><strong>Fix:</strong> remove <em><strong>id</strong></em>. Match the legacy schema exactly. SwiftData already gives you <em><strong>persistentModelID</strong></em> for identity. You do not need to invent a new column on day one.</p><p>After that fix the app launched and <strong><span>all my old favorites and saved routes were still there</span></strong>.</p><h3>What I Would Tell Another iOS Dev</h3><ol><li><p><strong><span>Check your scope first.</span></strong> If you have CloudKit sync, complex migrations, heavy background contexts&#8230; budget more time. My app was small. That helped a lot. However, I&#8217;m planning to think about CloudKit sync as well later.</p></li><li><p><strong><span>Match the old schema on the first pass.</span></strong> Do not add properties &#8220;while you are here.&#8221;</p></li><li><p><strong><span>Keep the same </span></strong><em><strong>.sqlite</strong></em><strong><span> path.</span></strong> Seriously. &#128521;</p></li><li><p><strong><span>Migrate file by file.</span></strong> Models &#8594; stack &#8594; app entry &#8594; views. You can keep the project compiling between steps if you are disciplined.</p></li><li><p><strong><span>Delete </span></strong><em><strong>.xcdatamodeld</strong></em><strong><span> last.</span></strong> Not first.</p></li><li><p><strong><span>Test on a device with real old data.</span></strong> Simulator with a fresh install will not teach you anything about schema mismatches.</p></li><li><p><em><strong>@Query</strong></em><strong><span> is not for view models.</span></strong> Only views. I learned this the boring way.</p></li></ol><div><hr></div><p>Core Data was not hurting me. But <em><strong>SwiftUI</strong></em> + <em><strong>@Query</strong></em> + <em><strong>@Model</strong></em> feels more elegant now, and I do not miss old age <em><strong>NSFetchRequest</strong></em> factory extensions one bit.</p><p>If you are sitting on a similar small Core Data layer and thinking about SwiftData, you can do it incrementally. Same locker, new key. Just do not change the lock on the first try &#128272;</p><p>Sources:</p><ul><li><p><a href="https://developer.apple.com/documentation/CoreData/adopting-swiftdata-for-a-core-data-app">Adopting SwiftData for a Core Data app</a></p></li></ul><div id="youtube2-oIsjgo4Qb4A" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;oIsjgo4Qb4A&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/oIsjgo4Qb4A?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div>]]></content:encoded></item><item><title><![CDATA[Swift - NSPointerArray/WeakCollections]]></title><description><![CDATA[Have you ever encountered to store weak references in an array in other words using arrays with weak references?]]></description><link>https://emredegirmenci.substack.com/p/swift-nspointerarrayweakcollections</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/swift-nspointerarrayweakcollections</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sat, 13 Jun 2026 14:04:12 GMT</pubDate><enclosure url="https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 424w, https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 848w, https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1272w, https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1456w" sizes="100vw"><img src="https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" width="382" height="571.9659706109823" data-attrs="{&quot;src&quot;:&quot;https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:3872,&quot;width&quot;:2586,&quot;resizeWidth&quot;:382,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;a dining room table&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="a dining room table" title="a dining room table" srcset="https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 424w, https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 848w, https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1272w, https://images.unsplash.com/photo-1639548538099-6f7f9aec3b92?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHwxMHx8Y2xhc3N8ZW58MHx8fHwxNzgxMjc2MTY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Photo by <a href="https://unsplash.com/@bitu2104">Tuyen Vo</a> on <a href="https://unsplash.com">Unsplash</a></figcaption></figure></div><p>Have you ever encountered to store weak references in an array in other words using arrays with weak references? I know that&#8217;s not a case for every array usage in Swift but that&#8217;s not an excuse not to know how to achieve it.</p><p>In Swift, standard arrays store <strong>strong references</strong> to the objects they contain. This means as long as an object is in the array, its reference count stays above zero, preventing it from being <strong>deallocated</strong> from memory.</p><h3>The Need for Weak References </h3><p>Sometimes, you want to keep track of a collection of objects without owning them. This is where you need <strong>weak references</strong>. If the only remaining reference to an object is in your collection, you want that object to be <strong>deallocated</strong> automatically to <strong>avoid</strong> <strong>memory leaks</strong>.</p><h3>Why weak references?</h3><p>For example, I have a <em><strong>HomeViewController </strong></em>and I wanna pop-up some custom views from there. I&#8217;m creating an array which points to the PopupView array strongly and injecting it in the initializer of HomeVC. Also, I have a ViewPresenter class to present my custom views and passing them to HomeVC. If we try to break the strong reference cycle in <em><strong>destroyViews()</strong></em> function it won&#8217;t happen since arrays are value types.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;feaf7745-ecb5-4abd-be2d-c7890af3ff1b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">import Foundation

class PopupView {}

class HomeViewController {
    private var views: [PopupView]? //Strong reference
    
    init(views: [PopupView]) {
        self.views = views
    }
    
    func poppAction() {
        print("Views popped up!")
    }
}

class ViewPresenter {
    private var popupA: PopupView? = PopupView()
    private var popupB: PopupView? = PopupView()
    private var homeVC: HomeViewController
    
    init() {
        self.homeVC = HomeViewController(views: [popupA!, popupB!])//injection of strong reference
    }
    
    func destroyViews () {
        popupA = nil
        popupB = nil //destroyViews doesn't destroy the two views 
        // because the array inside HomeViewController is maintaining a strong reference of the views.
    }
}</code></pre></div><p>We can avoid this problem replacing the array with <em><strong>NSPointerArray</strong></em>.</p><h3>What is NSPointerArray?</h3><p><em><strong>NSPointerArray</strong></em> is a Foundation class that acts like a specialized, mutable collection. Unlike a standard array, it can store <strong>weak references</strong> to objects.</p><p>It is specifically designed to store pointers. By configuring it with <em><strong>NSPointerArray.weakObjects</strong></em>, it doesn't increment the reference count of the objects you add to it.</p><p><a href="https://www.marcosantadev.com/swift-arrays-holding-elements-weak-references/">Using the following extension we can add convenience methods, allowing us to cast the </a><em><strong><a href="https://www.marcosantadev.com/swift-arrays-holding-elements-weak-references/">AnyObject</a></strong></em><a href="https://www.marcosantadev.com/swift-arrays-holding-elements-weak-references/"> pointers back to our specific class types easily. Using the pointer may be annoying, you can use this extension which made to simplify the </a><em><strong><a href="https://www.marcosantadev.com/swift-arrays-holding-elements-weak-references/">NSPointerArray</a></strong></em><a href="https://www.marcosantadev.com/swift-arrays-holding-elements-weak-references/">:</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;9b8467a3-9e6d-43d5-ae3f-e3c9ac1ac220&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">extension NSPointerArray {
    func addobject(_ object: AnyObject?) {
        guard let strongObject = object else { return }
        
        let pointer = Unmanaged.passUnretained(strongObject).toOpaque()
        addPointer(pointer)//: UnsafeMutableRawPointer?
    }
    
    func object(at index: Int) -&gt; AnyObject? {
        guard index &lt; count,
              let pointer = self.pointer(at: index) else { return nil }
        return Unmanaged&lt;AnyObject&gt;.fromOpaque(pointer).takeUnretainedValue()
    }
}</code></pre></div><p>We can get rid of <em><strong>Unmanaged.passUnreateined </strong></em>usage in the following code by using the above NSPointerArray extension!</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;1bd6dc04-7205-4517-bbcd-06d014206855&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">class MyView { }

var array = NSPointerArray.weakObjects()

let obj = MyView()
let pointer = Unmanaged.passUnretained(obj).toOpaque()
array.addPointer(pointer)</code></pre></div><p>We can replace the previous example with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;36be3bbb-69a7-42e2-bc79-7994b7159cd6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">class MyView { }

var array = NSPointerArray.weakObjects()

let obj = MyView()
array.addObject(obj)</code></pre></div><p>Let&#8217;s fix the problem in the <strong>Why weak references?</strong> code example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;0ac9fc2b-d975-40fd-ae45-cd2421c55c54&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">import Foundation

class PopupView {}

class HomeViewController {
    private let views: NSPointerArray
    
    init(views: NSPointerArray) {
        self.views = views
    }
    
    func poppAction() {
        print("Views popped up!")
    }
}

class ViewPresenter {
    private var popupA: PopupView? = PopupView()
    private var popupB: PopupView? = PopupView()
    private var homeVC: HomeViewController
    
    init() {
        let pointerArray = NSPointerArray.weakObjects()
        pointerArray.addObject(popupA)
        pointerArray.addObject(popupB)
        self.homeVC = HomeViewController(views: pointerArray)
    }
    
    func destroyViews () {
        popupA = nil // it now destroys the views from memory since we made them weak
        popupB = nil
    }
}

ViewPresenter().destroyViews()</code></pre></div><p>It now destroys the views from memory since we made them weak in the initializer of the ViewPresenter class. The <em><strong>NSPointerArray</strong></em> stores pointers of <em><strong>AnyObject</strong></em> only, it means that we can only store just classes (not structs or enums) but we can store protocols if they are class-bound;</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;103dda74-94d9-4d1f-b2cf-a1efc51af7f1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">protocol SomeProtocol: AnyObject {}</code></pre></div><p>The biggest concern about NSPointerArray is <strong>type safety</strong>. The compiler is not able to infer the type of the objects inside <em><strong>NSPointerArray</strong></em>, since it uses pointers of objects <em><strong>AnyObject</strong></em>. For this reason, when we get an object from the array, we must cast it to a specific type:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;b2fe52aa-1dbd-4ebf-b419-97d94e2d758f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">if let objectAtIndexZero = array.object(at: 0) as? Class { // Cast to Class
    print("The object at index zero is MyClass")
}</code></pre></div><p>To solve this type-cast problem we need a type-safe alternative generic class.<br>Let&#8217;s make the previous example type-safe by generic WeakConverter wrapper class;</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;cf0ea9f5-1a2d-46e2-b4a7-854fbc37b4ad&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">import Foundation

typealias WeakConverterView = WeakConverter&lt;PopupView&gt;

final class WeakConverter&lt;A: AnyObject&gt; {

    // private(set) exposes convert as read-only, 
    // so callers cannot reassign the weak reference from outside the class.
    private(set) weak var convert: A? 
    
    init(_ value: A?) {
        convert = value
    }
}

class PopupView {}

class HomeViewController {
    private let views: [WeakConverterView]
    
    init(views: [WeakConverterView]) {
        self.views = views
    }
    
    func popupAction() {
        print("Views popped up!")
        
    }
}

class ViewPresenter {
    private var popupA: PopupView? = PopupView()
    private var popupB: PopupView? = PopupView()
    private var homeVC: HomeViewController
    
    init() {
        var array = [WeakConverterView]()
        array.append(WeakConverterView(popupA))
        array.append(WeakConverterView(popupB))
        self.homeVC = HomeViewController(views: array)
    }
    
    func destroyViews () {
        popupA = nil
        popupB = nil
    }
}

ViewPresenter().destroyViews()</code></pre></div><p><em><strong>A: AnyObjec</strong></em><code>t</code> restricts the generic to class instances, which is required because only reference types support <em><strong>weak</strong></em>. The wrapper gives the compiler a concrete type. For example <em><strong>[WeakConverter&lt;PopupView&gt;]</strong></em>, so we no longer need casting when we use the array.</p><p>When <em><strong>destroyViews()</strong></em> runs,<em> <strong>popupA</strong> </em>and<em> <strong>popupB</strong> </em>release their strong references. The <em><strong>PopupView</strong> </em>instances are deallocated, and each <em><strong>WeakConverterView.convert</strong> </em>becomes <em><strong>nil</strong> </em>without<em> <strong>HomeViewController</strong> </em>needing to remove entries manually.</p><div><hr></div><p>If you want to see all of the code above in an editor you can watch my youtube video here;</p><div id="youtube2-m_Zq9e_kNPI" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;m_Zq9e_kNPI&quot;,&quot;startTime&quot;:&quot;105s&quot;,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/m_Zq9e_kNPI?start=105s&amp;rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><p>Sources:<br>- <a href="https://www.marcosantadev.com/swift-arrays-holding-elements-weak-references/">Swift Arrays Holding Elements With Weak References</a></p><p>- <a href="https://developer.apple.com/documentation/foundation/nspointerarray">NSPointerArray</a></p>]]></content:encoded></item><item><title><![CDATA[CloudKit Mystery in iOS]]></title><description><![CDATA[Why My App Showed Different Heatmap Data on Xcode and on the App Store?]]></description><link>https://emredegirmenci.substack.com/p/cloudkit-mystery-in-ios</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/cloudkit-mystery-in-ios</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sat, 23 May 2026 15:49:22 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!OSlf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I want to share a confusing CloudKit story that took me a full day of debugging to fully understand. Maybe it helps you if you also use CloudKit in your iOS app.</p><h3>What I Was Trying To Do</h3><p>My app, <a href="https://apple.co/4mz7vev">Walk Mate</a>, has a <strong>Heatmap</strong> screen with two filters: <em>&#8220;You&#8221;</em> and<em> &#8220;Others&#8221;</em>. The <em>&#8220;You&#8221;</em> filter shows places where the current user walked. The <em>&#8220;Others&#8221;</em> filter shows anonymous walks from all other users around the world. The data goes through CloudKit Public Database so every user can see everyone else&#8217;s walk place in a circular region. (i.e. over Taipei City and Kaohsiung City)</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!OSlf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!OSlf!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 424w, https://substackcdn.com/image/fetch/$s_!OSlf!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 848w, https://substackcdn.com/image/fetch/$s_!OSlf!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 1272w, https://substackcdn.com/image/fetch/$s_!OSlf!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!OSlf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png" width="266" height="523.3096590909091" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:2770,&quot;width&quot;:1408,&quot;resizeWidth&quot;:266,&quot;bytes&quot;:3393991,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/198973741?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!OSlf!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 424w, https://substackcdn.com/image/fetch/$s_!OSlf!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 848w, https://substackcdn.com/image/fetch/$s_!OSlf!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 1272w, https://substackcdn.com/image/fetch/$s_!OSlf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb610be88-b3d2-46ee-a414-efa51f9ff8f8_1408x2770.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>I shipped version 3.4.2 on App Store last week. Real users from Turkey, USA, Taiwan, Sweden, Japan and Canada started using the app. I could see their accounts in App Store Connect Analytics. But when I opened my Heatmap <em>&#8220;Others&#8221;</em> tab in the simulator from Xcode, I saw nothing from those countries. Only my own old test walks from Izmir, Turkey.</p><p>This was the start of the confusion. &#129327;</p><h3>First Mistake: I Trusted The Dashboard Without Checking The Environment</h3><p>I opened CloudKit Dashboard. I picked Production. I picked Public Database. I picked the record type HeatmapContribution. I saw a lot of records. Some had latitude around 25 (Taiwan), some around 35 (Japan), and so on. So I thought <em>&#8220;the data is there, the bug must be in my app.&#8221;</em></p><p>I asked an AI agent to help me debug. We added log lines to the fetch function. We ran the app from Xcode. The logs said the fetch returned 134 records. All of them were near Izmir. None from Taiwan or Japan.</p><p>This made no sense to me at first. The dashboard clearly showed Taiwan records. The app clearly did not return them. We tried many things. We checked sort orders. We checked permissions. We checked if records were lost during parsing. Nothing explained it.</p><h3>The Real Cause: Two CloudKit Worlds</h3><p>Then we tried one direct test. We picked one record name (9A85DFF-089E-4435-90E5-XXXXX) from the dashboard for a Taiwan record. We told the app to fetch that exact record by its name. The app got back <em>&#8220;Record not found&#8221;</em>.</p><p>That was the moment everything clicked.</p><p>CloudKit has two separate databases for every container. One is called <strong>Development</strong>. One is called <strong>Production</strong>. They look the same in code. They have the same name, the same record types, and the same fields. But they hold different data and they live on different servers.</p><p>When you build and run your app from Xcode with your normal Apple developer signing, your app talks to <strong>Development</strong>. When Apple builds your app for <strong>App Store</strong> or <strong>TestFlight</strong> with distribution signing, your app talks to <strong>Production</strong>. This switch happens because of the signing profile, not because of your build configuration. So even if I changed <strong>Xcode scheme</strong> to <strong>Release</strong>, my Xcode build still talked to Development as long as I used my normal developer certificate.</p><p>So my picture was like this:</p><ul><li><p>My old test walks from past development runs went into Development.</p></li><li><p>Real App Store users on 3.4.2 wrote their walks into Production.</p></li><li><p>The CloudKit Dashboard tab said &#8220;Production&#8221; at the top and showed me Production data.</p></li><li><p>My Xcode debug build read only Development data.</p></li></ul><p>Two different worlds. Looking at one in the dashboard, and reading the other in the app. They never met.</p><h3>A Second Bug Hiding Behind The Confusion</h3><p>While we were fixing the environment confusion, we found another real problem. The <em>&#8220;Others&#8221;</em> fetch in 3.4.2 was failing silently for every real user. Why? Because the Production schema needed a <strong>Queryable</strong> index on the system field recordName, and a <strong>Queryable</strong> + <strong>Sortable</strong> index on the timestamp field. These indexes were not deployed in Production. Without them, every <em>&#8220;Others&#8221;</em> query returned the error <em>&#8220;Field &#8216;recordName&#8217; is not marked queryable&#8221;.</em></p><p>The good news is that CloudKit indexes apply right away once you deploy them. So I went into the dashboard, added the indexes in <strong>Development</strong>, then clicked <em>&#8220;Deploy Schema Changes to Production&#8221;.</em> After that, my live App Store users could finally see global heatmap data without me shipping a new version.</p><p>I tested this by deleting Walk Mate from my iPhone, reinstalling 3.4.2 from the App Store, and opening the Heatmap. I saw my own Izmir walks under <strong>&#8220;You&#8221;</strong> and global walks by others under <strong>&#8220;Others&#8221;</strong>. It worked. &#128640;</p><h3>The Confusion Came Back</h3><p>Then I built and ran the same code from Xcode in Debug mode. The others walks were gone again. Only my local one under both <em>&#8220;You&#8221;</em> and <em>&#8220;Others&#8221;</em>. I thought I broke something. &#128558;&#8205;&#128168;</p><p>I did not break anything. The Xcode debug build was reading <strong>Development</strong> again. <strong>Development</strong> still has no Taiwan or others records because no real user ever writes to Development. Only my past tests wrote to <strong>Development</strong>. So the Heatmap <em>&#8220;Others&#8221;</em> looks empty in Xcode debug runs and that is the correct expected behavior.</p><h3>How To Verify Production Data From Xcode If You Want</h3><p>There are two ways:</p><ol><li><p>Add this temporary key in your entitlements file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;a3cb45e6-f0af-4f7e-a21a-da68e8cef0f7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">&lt;key&gt;com.apple.developer.icloud-container-environment&lt;/key&gt;
&lt;string&gt;Production&lt;/string&gt;</code></pre></div></li></ol><p>This forces Xcode builds to talk to <strong>Production</strong>. Use it only for testing and remove it after. Apple&#8217;s archive flow does not need this key. It picks <strong>Production</strong> by <strong>itself</strong> when it signs with a distribution profile.</p><ol start="2"><li><p>Distribute the build through <strong>TestFlight Internal Testing</strong>. TestFlight builds use <strong>Production</strong> by default. So if you install your TestFlight build on a simulator or a device, you will see real user data.</p></li></ol><h3>What I Learned</h3><p>A few simple things I now keep in mind:</p><ul><li><p><strong>CloudKit Development</strong> and <strong>CloudKit Production</strong> are not the same database. They look the same in code. They are not the same at runtime.</p></li><li><p>The <strong>CloudKit Dashboard</strong> tab you are looking at controls only the dashboard view. Your app picks its own database based on signing.</p></li><li><p>Schema changes and index changes only apply to one environment. You must click <em>&#8220;Deploy Schema Changes&#8221;</em> to push them from <strong>Development</strong> to <strong>Production</strong>.</p></li><li><p><strong>Indexes</strong> are <strong>not</strong> <strong>auto created</strong> in <strong>Production</strong> by save. Records can be saved without indexes but cannot be queried. So <strong>users</strong> will <strong>quietly upload data</strong> that <strong>nobody can ever read</strong> until you <strong>deploy the indexes.</strong></p></li><li><p>A simple query log that prints the count of records returned would have saved me hours. I added that log on day one for next time.</p></li><li><p>A direct fetch by record name is the fastest way to confirm <em>&#8220;is this record actually in the database my app talks to&#8221;.</em> If you get back <em>&#8220;Record not found&#8221;</em> for a record you can clearly see in the dashboard, you are not in the same database.</p></li></ul><p>The bug felt huge while I was inside it. The fix in code was tiny. The fix in dashboard was just a few clicks. The real lesson was about understanding which <strong>CloudKit</strong> world I was talking to at any moment. &#129300;</p>]]></content:encoded></item><item><title><![CDATA[What's the difference between frame and bounds?]]></title><description><![CDATA[Frame and Bounds in UIKit]]></description><link>https://emredegirmenci.substack.com/p/whats-the-difference-between-frame</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/whats-the-difference-between-frame</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sun, 17 May 2026 07:01:34 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!rakk!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Another most asked question in iOS interviews is <strong>&#8220;</strong><em><strong>Tell me the difference between frame and bounds&#8221;</strong></em> with a follow-up <strong>&#8220;</strong><em><strong>What happens when you rotate a view</strong></em><strong>?&#8221;.</strong> Imagine, you are applying a transformation to a view, what happens in terms of frame and bounds. Are they gonna be change or stay in same values? </p><p>Short answer is;</p><p><strong>Frame =</strong> a view&#8217;s <strong>location</strong> and size using the <strong>parent view&#8217;s coordinate system</strong>. Placing the view on the parent.</p><p><strong>Bounds =</strong> a view&#8217;s <strong>location</strong> and size using its <strong>own coordinate system</strong>. Placing the view&#8217;s content or subview within itself.</p><p>If you think about the view frame in its parent coordinate system when you are rotating, you are changing parent view&#8217;s coordinate system. <strong>Bounds</strong> value will be <strong>same</strong> and <strong>frame</strong> value <strong>changes</strong>. Figure 1.0 shows how it looks like before transformation (rotating) and look carefully to the values.<strong> </strong></p><p>The values in <em><strong>CGRect(x: 5, y: 5, width: 30, height: 40)</strong></em> define the rectangle's position and size:</p><ul><li><p><strong>x (5):</strong> The horizontal distance from the left edge of the parent view to the rectangle&#8217;s origin.</p></li><li><p><strong>y (5):</strong> The vertical distance from the top edge of the parent view to the rectangle&#8217;s origin.</p></li><li><p><strong>width (30)</strong>: The horizontal span of the rectangle.</p></li><li><p><strong>height (40)</strong>: The vertical span of the rectangle.</p></li></ul><p>These values specifically define the <strong>frame</strong> of the view, representing its location and size within its superview (parent) coordinate system.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!rakk!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!rakk!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 424w, https://substackcdn.com/image/fetch/$s_!rakk!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 848w, https://substackcdn.com/image/fetch/$s_!rakk!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 1272w, https://substackcdn.com/image/fetch/$s_!rakk!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!rakk!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png" width="304" height="349.1393939393939" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:758,&quot;width&quot;:660,&quot;resizeWidth&quot;:304,&quot;bytes&quot;:39501,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/197974405?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!rakk!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 424w, https://substackcdn.com/image/fetch/$s_!rakk!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 848w, https://substackcdn.com/image/fetch/$s_!rakk!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 1272w, https://substackcdn.com/image/fetch/$s_!rakk!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa646abf1-9471-4ef0-a6c8-25e2df6834b9_660x758.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Figure 1.0: UIView coordinate systems (with example values)</figcaption></figure></div><h4><em><strong>View:</strong></em></h4><p>frame = { 5, 5, 30, 40 }<br>bounds = { 0, 0, 30, 40 }<br>center = { 20, 25 }</p><p>When the view rotated:</p><ul><li><p><strong>Frame (changed):</strong> Because the view is now at an angle, it occupies more &#8220;horizontal and vertical space&#8221; relative to the <strong>parent&#8217;s X</strong> and <strong>Y</strong> axes.</p></li><li><p><strong>Bounds (unchanged):</strong> The view&#8217;s internal size remains (30 x 40) because the object itself hasn&#8217;t grown.</p></li></ul><p>The dashed line in Figure 1.1 shows rotation and parent view&#8217;s location. Its <strong>width (52)</strong> and <strong>height (54)</strong> are calculated based on the rotated corners of the original (30 x 40) rectangle. Its <strong>corners</strong> push further out along the <strong>X</strong> and <strong>Y</strong> axes. To keep the view contained the parent system must calculate a new bounding box (the dashed line in Figure1.1) that reaches these new outermost points. The distance between its <strong>leftmost corner</strong> and <strong>rightmost corner</strong> is now <strong>52</strong>, and the distance between its <strong>topmost</strong> and <strong>bottommost</strong> corner is <strong>54</strong>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!B5MX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!B5MX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 424w, https://substackcdn.com/image/fetch/$s_!B5MX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 848w, https://substackcdn.com/image/fetch/$s_!B5MX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 1272w, https://substackcdn.com/image/fetch/$s_!B5MX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!B5MX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png" width="310" height="340" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:816,&quot;width&quot;:744,&quot;resizeWidth&quot;:310,&quot;bytes&quot;:76367,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/197974405?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!B5MX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 424w, https://substackcdn.com/image/fetch/$s_!B5MX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 848w, https://substackcdn.com/image/fetch/$s_!B5MX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 1272w, https://substackcdn.com/image/fetch/$s_!B5MX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F12b37c1f-a269-4faf-b7c1-df45a0806283_744x816.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Figure 1.1: The effect rotating a view has on its frame property</figcaption></figure></div><h4><em><strong>View:</strong></em></h4><p>frame = { -6, -2, 52, 54 } // changed<br>bounds = { 0, 0, 30, 40 } // unchanged<br>center = { 20, 25 } // unchanged</p>]]></content:encoded></item><item><title><![CDATA[Synchronizing the access to the common resource]]></title><description><![CDATA[DispatchBarrier vs Actor]]></description><link>https://emredegirmenci.substack.com/p/synchronizing-the-access-to-the-common</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/synchronizing-the-access-to-the-common</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Thu, 14 May 2026 20:44:34 GMT</pubDate><enclosure url="https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 424w, https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 848w, https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1272w, https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1456w" sizes="100vw"><img src="https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080" width="500" height="333.3333333333333" data-attrs="{&quot;src&quot;:&quot;https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:4000,&quot;width&quot;:6000,&quot;resizeWidth&quot;:500,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;white and red sedan on road during daytime&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="white and red sedan on road during daytime" title="white and red sedan on road during daytime" srcset="https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 424w, https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 848w, https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1272w, https://images.unsplash.com/photo-1588362951121-3ee319b018b2?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw0fHxiYXJyaWVyfGVufDB8fHx8MTc3ODc5MTkyNHww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=1080 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Photo by <a href="https://unsplash.com/@maurosbicego">Mauro Sbicego</a> on <a href="https://unsplash.com">Unsplash</a></figcaption></figure></div><p>I&#8217;m back with another concurrency question asked in one of the interviews like:</p><p>&#8220;When you have a Singleton (shared resource) in a multi-threaded environment, multiple queues can access to Singleton, the same shared state. You need to use a mechanism for accessing the common resource synchronously. What else do you remember for synchronizing the access to the common resource other than <strong>NSLock</strong> and <strong>DispatchSemaphore</strong>?&#8221;<br><br>I answered with <strong>DispatchBarrier</strong> by trying my best to explain. Let&#8217;s deep dive into the <strong>DispatchBarrier </strong>and then the modern way!</p><h3>DispatchBarrier</h3><p>It tells the compiler; &#8220;Wait. Let&#8217;s finish all the work that has already started. Then, just ME will work. Once I&#8217;m finished, the other tasks can continue.&#8221; In order to prevent data race, run tasks in an ordered way. Don&#8217;t run read and write operations at the same time! Once at a time!<br><br>Let&#8217;s explain it with a real life scenario;<br><br>Concurrent queue = multi-lane highway &#128739;&#65039;</p><ul><li><p>Read operations = normal cars &#128663;</p></li><li><p>Write operations = road maintenance car &#128679; </p></li></ul><p>When the Dispatch Barrier arrives;</p><ol><li><p>All cars pass first,</p></li><li><p>Road is closed completely,</p></li><li><p>Road maintenance car works alone,</p></li><li><p>Road opens again once the maintenance completed.</p></li></ol><p>Let&#8217;s jump into the code example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;ef3d4f3f-fc8b-4c73-a3d7-1e90d3d7596d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">final class Highway {

    private let roadQueue = DispatchQueue( // 1
        label: "highway.queue",
        attributes: .concurrent
    )

    // Current road status
    private var roadStatus = "Road is Open"

    // Normal cars
    func carPass(carName: String) {

        roadQueue.async { // 2
            print("&#128663; \(carName) is passing...")
            sleep(2)
            print("&#9989; \(carName) passed")
        }
    }

    // Road maintenance car is coming
    func roadMaintenance() {

        roadQueue.async(flags: .barrier) { // 3
            print("&#128721; Road is closing...")
            print("&#128736;&#65039; Maintenance car is working...")
            
            sleep(3)

            self.roadStatus = "Maintenance Completed!"

            print("&#9989; Maintenance is done")
            print("&#128994; Road opens again")

            // new cars can pass concurrently again
        }
    }
}</code></pre></div><p>Let&#8217;s dive into comment number lines:</p><ol><li><p>Create a concurrent queue</p></li><li><p>async + concurrent queue</p></li><li><p>Use barrier. At this point queue behaves as serial temporarily.</p></li></ol><p>Usage:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;51c36ae1-e2ef-4248-b411-269907be7ce0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">let highway = Highway()

highway.carPass(carName: "Car 1")
highway.carPass(carName: "Car 2")
highway.carPass(carName: "Car 3")

highway.roadMaintenance()

highway.carPass(carName: "Car 4")
highway.carPass(carName: "Car 5")</code></pre></div><p>The workflow would roughly be like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;1e6aeeff-7ea1-47e2-a1f2-9771d30421ad&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">&#128663; Car 1 is passing...
&#128663; Car 2 is passing...
&#128663; Car 3 is passing...

&#9989; Car 1 passed
&#9989; Car 2 passed
&#9989; Car 3 passed

&#128721; Road is closing...
&#128736;&#65039; Maintenance car is working...

&#9989; Maintenance is done
&#128994; Road opens again

&#128663; Car 4 is passing...
&#128663; Car 5 is passing...</code></pre></div><p>The critical points are here:</p><ul><li><p>The first 3 car can go at the same time (<em><strong>concurrent</strong></em>)</p></li><li><p>Everybody waits when the maintenance car arrive (<em><strong>barrier</strong></em>)</p></li><li><p>Road opens again when the maintenance completed.</p></li></ul><h3>Modern Way Actor</h3><p>In a modern Swift Concurrency way, actors allow us to <strong>protect shared mutable state</strong> from <strong>data races</strong>. It means that an <strong>actor</strong> can help us <strong>fix a data race</strong> in a same way we had before by serializing access to mutable state. This is what we did manually with the <strong>DispatchBarrier </strong>earlier except actors are a lot smarter than the naive <strong>.barrier</strong> approach we took earlier.</p><p>Let&#8217;s update the code example with the modern and safer way:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;965b7ebe-61f5-4784-ba99-659ed38b8af8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// 1
actor Highway {

    // Current road status
    private var roadStatus = "Road is Open" // 2

    // Normal cars
    func carPass(carName: String) async {

        print("&#128663; \(carName) is passing...")
        try? await Task.sleep(for: .seconds(2)) // 3
        print("&#9989; \(carName) passed")
    }

    // Road maintenance car is coming
    func roadMaintenance() async {

        print("&#128721; Road is closing...")
        print("&#128736;&#65039; Maintenance car is working...")

        try? await Task.sleep(for: .seconds(3))

        roadStatus = "Maintenance Completed!"

        print("&#9989; Maintenance is done")
        print("&#128994; Road opens again")
    }

    func currentStatus() -&gt; String {
        roadStatus
    }
}</code></pre></div><p>Let&#8217;s dive into comment number lines:</p><ol><li><p>I will queue access to the mutable state within this object.</p><ul><li><p>No need .barrier definition</p></li><li><p>No queue creation</p></li><li><p>No synchronization code</p></li></ul></li><li><p>actor protects state of <em><strong>private var roadStatus</strong></em></p><ul><li><p>Two tasks cannot change state simultaneously</p></li><li><p>One cannot interrupt while the other is changing state</p></li><li><p>No data race</p></li></ul></li></ol><h4>Similarity with DispatchBarrier</h4><p>Mentally that&#8217;s what actor makes under the hood: <em><strong>concurrent queue + barrier. </strong></em>But the main difference is: in the <strong>.barrier </strong>approach you write synchronization manually, in the <strong>actor, </strong>Swift runtime manages synchronization automatically.</p><div><hr></div><h3>Conclusion</h3><p>In the <strong>Dispatch Barrier</strong> version of our Highway example, every car that wanted to access the road had to wait for the barrier operation to finish before continuing. During this waiting period, the underlying thread was <strong>blocked</strong>. In other words, the thread could not do any other useful work until the road maintenance operation completed.</p><p>This is one of the biggest differences between traditional GCD synchronization techniques and Swift Concurrency.</p><p>With <strong>Dispatch Barrier</strong>, we manually tell the queue: <strong>&#8220;Stop all traffic temporarily and let this critical operation run alone.&#8221; </strong>While this approach is safe and effective, waiting tasks often end up blocking threads.</p><p><strong>Actors</strong> take a different approach. By converting our Highway type from a <strong>class</strong> into an <strong>actor</strong>, Swift automatically <strong>serializes</strong> access to <strong>mutable state</strong> for us. Instead of blocking threads while waiting for access, Swift Concurrency <strong>suspends</strong> tasks.</p><p>So mentally, you can think of the difference like this:</p><p><strong>Dispatch Barrier</strong></p><blockquote><p>&#8220;Block the road until maintenance is done.&#8221;</p></blockquote><p><strong>Actor</strong></p><blockquote><p>&#8220;Cars waiting for maintenance don&#8217;t block the road itself. They pause and resume later.&#8221;</p></blockquote><p>This is one of the core ideas behind Swift Concurrency: <strong>Suspend tasks, not threads.</strong></p><p></p><p><strong>Sources: </strong></p><ul><li><p><a href="https://developer.apple.com/documentation/swift/managing-a-shared-resource-using-a-singleton">Apple Developer - Managing a Shared Resource Using a Singleton</a></p></li></ul><ul><li><p><a href="https://stackoverflow.com/questions/49160125/thread-safe-singleton-in-swift">Thread safe singleton in swift - Stack Overflow</a></p></li><li><p><a href="https://stackoverflow.com/a/76942501/4442254">Stack Overflow Legend Rob</a> &#129321;</p></li><li><p><a href="https://www.donnywals.com/books/">Practical Swift Concurrency - Donny Wals</a></p></li></ul>]]></content:encoded></item><item><title><![CDATA[Modern Semaphore]]></title><description><![CDATA[How to end an iOS Live Activity on app termination?]]></description><link>https://emredegirmenci.substack.com/p/modern-semaphore</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/modern-semaphore</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Mon, 11 May 2026 09:52:37 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!FczD!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!FczD!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!FczD!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 424w, https://substackcdn.com/image/fetch/$s_!FczD!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 848w, https://substackcdn.com/image/fetch/$s_!FczD!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!FczD!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!FczD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg" width="384" height="451.2309344790548" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1094,&quot;width&quot;:931,&quot;resizeWidth&quot;:384,&quot;bytes&quot;:181362,&quot;alt&quot;:&quot;black traffic light on green light&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/jpeg&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-normal" alt="black traffic light on green light" title="black traffic light on green light" srcset="https://substackcdn.com/image/fetch/$s_!FczD!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 424w, https://substackcdn.com/image/fetch/$s_!FczD!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 848w, https://substackcdn.com/image/fetch/$s_!FczD!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!FczD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F585efeab-06f3-49a9-8ad9-eed8bd7d3a7c_931x1094.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Photo by <a href="https://unsplash.com/@rodrigocuri">Rodrigo Curi</a> on <a href="https://unsplash.com">Unsplash</a></figcaption></figure></div><p>Most recently, when I was trying to end Live Activities completely and immediately and remove from both Dynamic Island and lock screen when the app terminated, I had a nondeterministic behavior. That Live Activity was sometimes removed sometimes not. After conducting some research, I discovered the solution using both modern Swift Concurrency and old Semaphore like below.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;7ecbd1b1-d55a-41d6-9993-6f47508ea9cb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">final class AppDelegate: NSObject, UIApplicationDelegate {
    
    func applicationWillTerminate(_ application: UIApplication) {
        let semaphore = DispatchSemaphore(value: 0)
        
        // Intentional: on termination we have a tiny window to end Live Activities.
        // A detached task avoids main-actor inheritance while we do a short bounded wait.
        Task.detached(priority: .high) {
            for activity in Activity&lt;WalkActivityAttributes&gt;.activities {
                let finalContent = ActivityContent(state: activity.content.state, staleDate: nil)
                await activity.end(finalContent, dismissalPolicy: .immediate)
            }
            // the semaphore&#8217;s .signal method is called to increment the number of available resources
            // so that whoever is waiting to access our resource can eventually gain access
            semaphore.signal()
        }
        // Every time we call .wait on the semaphore, the number of available resources either
        // decreases, or we wait for a resource to become available.
        _ = semaphore.wait(timeout: .now() + 2)
    }
}</code></pre></div><p>Let&#8217;s visualize that code in the <a href="https://www.geeksforgeeks.org/operating-systems/dining-philosopher-problem-using-semaphores/">Dining Philosopher Problem</a> manner but from the restaurant staff perspective. </p><p>The main purpose here is to clear the Live Activity as quickly as possible before the app completely terminates.</p><p>But the problem is;</p><ul><li><p><em><strong>activity.end(...)</strong></em> works async,</p></li><li><p>the process may be  interrupted,</p></li><li><p>Result: Live Activity stays remaining in both screens.</p></li></ul><p>That semaphore solution does; wait for a while before the app termination and Live Activity cleaning ends.<br><br>Let&#8217;s visualize in the restaurant analogy &#128071;</p><h3>Restaurant Analogy</h3><p>You are the restaurant owner. Restaurant will close soon. But there are still customers eating:</p><ul><li><p>Live Activities appear in lock screen</p></li><li><p>Live Activities in the Dynamic Island</p></li></ul><p>You say: <em>&#8220;Get out all customers before restaurant close &#129324;&#8221;</em></p><h3>1. Creating Semaphores</h3><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;0dc2f9b6-2711-436b-95a6-9c316da0edc8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">let semaphore = DispatchSemaphore(value: 0)</code></pre></div><p>It means, put a waiter to the door. But the waiter at first in &#8220;None of the customers have not completed their dinner.&#8220; status. So, the restaurant owner (app) has to wait.</p><p><em><strong>value: 0</strong></em> means there is no allowance to close restaurant.</p><h3>2. Cleaning starts in background</h3><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;2bf2511d-ed11-4dcd-a193-cfecdbd8c70d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Task.detached(priority: .high) {}</code></pre></div><p>Send waiters to clean tables quickly.</p><p>Logic behind <em><strong>detached</strong></em> is &#8220;have a separate team work without involving the main restaurant manager&#8221;</p><h3>3. Remove all customers</h3><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;673cba12-5093-4dc5-b6ed-b42e85988e0e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">for activity in Activity&lt;WalkActivityAttributes&gt;.activities {
    await activity.end(...)
}</code></pre></div><p>This means, the waiter goes to all tables and says <em>&#8220;Restaurant is closing, you must leave.&#8220;</em><br><br><em><strong>await</strong></em> is important because, the waiter is really expecting customers to leave. But, this doesn&#8217;t happen immediately.</p><h3>4. Notify the waiter when the job is finished</h3><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;50ca067a-0ef4-45e8-9017-2ec3dacbf8bb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">semaphore.signal()</code></pre></div><p>It means, the waiter comes and tells <em>&#8220;Finally, all customers are gone &#128558;&#8205;&#128168;&#8220;</em></p><p><strong>DispatchSemaphore</strong> value becomes <strong>0 &#8594; 1</strong>. So, we can close the restaurant.</p><h3>5. App waits for a bit</h3><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;6e5965de-6acb-43b5-9eca-6b33794e1ed8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">_ = semaphore.wait(timeout: .now() + 2)</code></pre></div><p>The restaurant owner waits on the doorstep and yells to staff <em>&#8220;I&#8217;ll wait for 2 sec until cleaning completes&#8220;.</em></p><p>If:</p><ul><li><p>Staff finish their job &#8594; restaurant closes properly.</p></li><li><p>If they can&#8217;t &#8594; time&#8217;s up &#8594; restaurant closes.</p></li></ul><h3>Why this code is important?</h3><p>Because <strong>iOS doesn&#8217;t wait</strong> <strong>async jobs</strong> while app is <strong>terminating</strong>.</p><p>So, normally if you do that app can directly close:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;554a8961-ba77-4eb5-ad4f-e3944ab34148&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">await activity.end()</code></pre></div><p>Semaphore solution says, <em>&#8220;One minute, let&#8217;s finish this job before close&#8221;.</em></p><h3>What does Semaphore represent here?</h3><p>In the restaurant example:</p><p><em><strong>wait(): </strong></em>The waiter waits on the doorstep.</p><p><em><strong>signal(): </strong></em>The staff says &#8220;Job is done&#8221;.</p><p><em><strong>value:</strong></em> <em><strong>0: </strong></em>No close.</p><p><em><strong>value: 1: </strong></em>Can be close now.</p><h3>Briefly</h3><ol><li><p>App will terminate</p></li><li><p>Start closing Live Activities</p></li><li><p>Wait a short while so the app doesn&#8217;t close immediately</p></li><li><p>Continue when the work is finished</p></li><li><p>Close again after 2 sec max</p></li></ol><p>Semaphore here works like a:</p><p>&#8220;Traffic police who manages closing road for a short time&#8221; &#128578;</p><h3>The Modern Way</h3><p><em><strong>DispatchSemaphore</strong></em> is an old school synchronization tool which comes from Grand Central Dispatch world. In the modern swift concurrency approach <em><strong>async/await</strong></em> and <em><strong>Task</strong></em> usage will be chosen.</p><p>In theory we can use something like below for terminating Live Activities:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;b85c7fe0-d211-43eb-8cb0-67480462ce28&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">func endAllActivities() async {
    for activity in Activity&lt;WalkActivityAttributes&gt;.activities {
        let finalContent = ActivityContent(
            state: activity.content.state,
            staleDate: nil
        )

        await activity.end(
            finalContent,
            dismissalPolicy: .immediate
        )
    }
}</code></pre></div><p>and we can call it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;de20ca32-2666-4d2d-9aa7-d003d5d45f56&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">Task {
    await endAllActivities()
}</code></pre></div><p>However, there is an important problem here is the <em><strong>applicationWillTerminate </strong></em>is<em> </em><strong>not</strong><em> </em>an <strong>async</strong> lifecycle callback. iOS doesn&#8217;t guarantee the completion of async tasks when  terminating and application. Therefore, while using only <em><strong>Task {} </strong></em>is more modern in theory, it may not be reliable in practice.</p><p>Hence, the most healthier approach is not doing Live Activity cleanup while app termination but doing it in either while switching to background or doing it before the scene has been inactive.</p><p>For instance:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;1448eeb3-ad78-4e1e-b3ff-e83a8440aef4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">func sceneDidEnterBackground(_ scene: UIScene) {
    Task {
        await endAllActivities()
    }
}</code></pre></div><p>This approach:</p><ul><li><p>It is natively compatible with Swift Concurrency</p></li><li><p>It does not cause thread blocking</p></li><li><p>It removes Semaphore requirement</p></li><li><p>It increases completion possibility of async tasks</p></li></ul><div><hr></div><h3>Conclusion</h3><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!oTSD!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!oTSD!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 424w, https://substackcdn.com/image/fetch/$s_!oTSD!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 848w, https://substackcdn.com/image/fetch/$s_!oTSD!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 1272w, https://substackcdn.com/image/fetch/$s_!oTSD!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!oTSD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png" width="1324" height="266" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:266,&quot;width&quot;:1324,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:105974,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196920491?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!oTSD!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 424w, https://substackcdn.com/image/fetch/$s_!oTSD!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 848w, https://substackcdn.com/image/fetch/$s_!oTSD!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 1272w, https://substackcdn.com/image/fetch/$s_!oTSD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F366a82f6-e5ce-4f41-b0cc-afd42160eea4_1324x266.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p><em><strong>Task + async/await</strong></em> is the modern approach but there is no 100% guarantee during the app termination. Hence, the best approach is doing cleanup at the early stages of the lifecycle.</p><h3></h3><p></p><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[What do you do when you want to get notified when a bunch of async tasks have finished?]]></title><description><![CDATA[DispatchGroup vs TaskGroup]]></description><link>https://emredegirmenci.substack.com/p/what-do-you-do-when-you-want-to-get</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/what-do-you-do-when-you-want-to-get</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Thu, 07 May 2026 16:21:50 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!PIrl!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!PIrl!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!PIrl!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 424w, https://substackcdn.com/image/fetch/$s_!PIrl!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 848w, https://substackcdn.com/image/fetch/$s_!PIrl!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 1272w, https://substackcdn.com/image/fetch/$s_!PIrl!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!PIrl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png" width="480" height="280.2631578947368" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:710,&quot;width&quot;:1216,&quot;resizeWidth&quot;:480,&quot;bytes&quot;:426151,&quot;alt&quot;:&quot;Apple Developer - WWDC21 - Swift concurrency: Behind the scenes&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196658260?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Apple Developer - WWDC21 - Swift concurrency: Behind the scenes" title="Apple Developer - WWDC21 - Swift concurrency: Behind the scenes" srcset="https://substackcdn.com/image/fetch/$s_!PIrl!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 424w, https://substackcdn.com/image/fetch/$s_!PIrl!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 848w, https://substackcdn.com/image/fetch/$s_!PIrl!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 1272w, https://substackcdn.com/image/fetch/$s_!PIrl!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ba8870e-750d-4ed2-9442-ec5a00dd8087_1216x710.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Apple Developer - WWDC21 - Swift concurrency: Behind the scenes</figcaption></figure></div><p>Nowadays, I&#8217;m brushing up my iOS concurrent programming skills based on my past iOS interview experiences. I&#8217;ve been creating an interview questions pool for the past 5 years based on the interviews that I have so far. Just after a week, the modern Swift Concurrency officially announced by Apple, I had a technical interview with Spotify and faced beautiful concurrency questions. Based on these questions, I decided to write some comparison article between <strong>DispatchGroup(</strong>legacy way<strong>) </strong>and <strong>TaskGroup </strong>(modern way). Let&#8217;s start with the legacy one!</p><h3>DispatchGroup</h3><p>It&#8217;s briefly used when you have a bunch of asynchronous tasks running in parallel and wait for all work to be completed in a given queue and you want to <strong>be notified</strong> when all of them are <strong>finished</strong>. Dispatch group have various work items to execute, and perform another work item once all of our work items are completed. A dispatch group doesn&#8217;t actually execute work and it&#8217;s up to you to decide where and how all of your work runs. It only tracks the number of tasks that you&#8217;ve started, and the number of tasks that you&#8217;ve completed.<br><br>Let&#8217;s look at the example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;55b4685f-c73f-4229-a350-08189b6933bc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">func fetchUserProfiles() {
    let group = DispatchGroup() // 1
    var results = [Data]()
    let userIDs = [1, 2, 3]
    let urls = userIDs
        .compactMap { URL(string: "https://jsonplaceholder.typicode.com/users/\($0)") }

    for url in urls {
        group.enter() // 3
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let data {
                results.append(data)
            }
            defer { group.leave() } // 4
        }.resume()
    }

    group.notify(queue: .main) { [weak self] in // 2
        self?.textLabel.text = "All jobs have completed!"
    }
}</code></pre></div><p>The code above kicks off an API call for each url, appends the fetched user data to an array, and once all API calls done the <em><strong>textLabel.text</strong></em> will be <em><strong>&#8220;All jobs have completed!&#8221;</strong></em>. </p><p>Let&#8217;s deep dive into comment lines:</p><ol><li><p>Create a new <em><strong>DispatchGroup()</strong></em> to track the number of times we start work, and the number of times we complete work.</p></li><li><p>Schedule a work item (update textLabel&#8217;s text) that will be executed on the main thread (indicated dispatch queue) once all work in the group is done. <em><strong>Note: The notification is itself asynchronous, so it&#8217;s possible to submit more jobs to the group after calling notify, as long as the previously submitted jobs haven&#8217;t already completed. </strong></em>(<a href="https://www.kodeco.com/books/concurrency-by-tutorials/v2.0">Source: Concurrency by Tutorials - Ray Wenderlich</a>)</p></li><li><p>For each url that was created, <strong>enter</strong> the dispatch group. It will increment the counter for the number of running tasks every time we enter the group.</p></li><li><p>Once the API call done, independently from the error or success cases, leave the dispatch group. Otherwise, you will never be signaled of completion. Once the last task is completed, <em><strong>notify(queue:)</strong></em> callback method will be informed.<br><br>As you can see the dispatch groups are very handy tool. Now, let&#8217;s see the Swift Concurrency version.</p></li></ol><h3>TaskGroup</h3><p>It&#8217;s pretty much like <strong>DispatchGroup</strong>, but in the modern <strong>Swift Concurrency</strong> world. A <strong>TaskGroup</strong> manages a dynamic number of child tasks, and automatically waits for all of them to complete before returning. Unlike <strong>DispatchGroup</strong>, it doesn&#8217;t track counters manually (<em>comment line // 3</em> in the previous code block) instead, it ties the lifetime of child tasks directly to the group itself.</p><p>Let&#8217;s look at the example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;1d1f8cc5-d135-4fdd-a490-a501dd622b65&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">@MainActor
func fetchUserProfiles() async {
    var results = [Data]()
    let userIDs = [1, 2, 3]
    let urls = userIDs
        .compactMap { URL(string: "https://jsonplaceholder.typicode.com/users/\($0)") }

    await withTaskGroup(of: Data?.self) { group in // 1
        for url in urls {
            group.addTask { // 3
                let (data, _) = try? await URLSession.shared.data(from: url)
                return data
            }
        }

        for await data in group { // 2
            if let data {
                results.append(data)
            }
        }
    } // 4

    textLabel.text = "All jobs have completed"
}</code></pre></div><p>I used exact same code example in a <strong>TaskGroup </strong>way. </p><p>Let&#8217;s deep dive into comment lines:</p><ol><li><p><em><strong>withTaskGroup(of:)</strong></em> creates a new task group, similar to <em><strong>DispatchGroup()</strong></em>. Unlike <strong>DispatchGroup</strong> though, you declare upfront what type each child task will produce (i.e <strong>Data?.self</strong>).</p></li><li><p><em><strong>for await data in group</strong></em> replaces <em><strong>group.notify(queue:)</strong></em>. Instead of scheduling a callback to fire when all work is done, you iterate over results as each child task completes. The loop <strong>ends naturally</strong> once every <strong>child task finishes</strong>.</p></li><li><p><em><strong>group.addTask {}</strong></em> replaces the <em><strong>group.enter()</strong></em> + <em>closure</em> + <em><strong>group.leave()</strong></em> threes*me &#128586;. Adding a task implicitly enters the group, and returning from the closure implicitly leaves it, no risk of forgetting a <em><strong>leave()</strong></em>.</p></li><li><p>The closing brace of <em><strong>withTaskGroup</strong></em> is the moment all child tasks are guaranteed to be done. This is where <em><strong>group.notify(queue:)</strong></em> would have fired in the <strong>DispatchGroup</strong> world. After this point, <em><strong>results</strong></em> is safe to use.</p></li></ol><blockquote><p><strong>Note:</strong> Even though <em><strong>fetchUserProfiles()</strong></em> is marked <em><strong>@MainActor</strong></em>, the network calls inside <em><strong>addTask</strong></em> will never run on the main thread. URLSession is in charge of its own execution context, not the caller. So marking the function <em><strong>@MainActor</strong></em> is completely safe and won&#8217;t block the main thread. Thanks Matt<strong> </strong>for pointing out! &#129321;</p><div class="bluesky-wrap outer" style="height: auto; display: flex; margin-bottom: 24px;" data-attrs="{&quot;postId&quot;:&quot;3mldis5besk2g&quot;,&quot;authorDid&quot;:&quot;did:plc:klsh7edzj3jmxucibyjqstb3&quot;,&quot;authorName&quot;:&quot;Matt Massicotte&quot;,&quot;authorHandle&quot;:&quot;massicotte.org&quot;,&quot;authorAvatarUrl&quot;:&quot;https://cdn.bsky.app/img/avatar/plain/did:plc:klsh7edzj3jmxucibyjqstb3/bafkreiczw6bcnaj3tp7vupdgjuqb47e2azwzekwwj7gm5lqivyf5ingucq&quot;,&quot;text&quot;:&quot;First, that's very nice of you!\n\nAlso, this was not a test! I was just wondering because there wasn't enough context! And I really didn't mean to stress you out so late!\n\n(Also, don't forget, there is no way to run the network call on main, no matter what you do - callee is in charge!)&quot;,&quot;createdAt&quot;:&quot;2026-05-08T10:16:38.496Z&quot;,&quot;uri&quot;:&quot;at://did:plc:klsh7edzj3jmxucibyjqstb3/app.bsky.feed.post/3mldis5besk2g&quot;,&quot;imageUrls&quot;:[]}" data-component-name="BlueskyCreateBlueskyEmbed"><iframe id="bluesky-3mldis5besk2g" data-bluesky-id="4671951435810804" src="https://embed.bsky.app/embed/did:plc:klsh7edzj3jmxucibyjqstb3/app.bsky.feed.post/3mldis5besk2g?id=4671951435810804" width="100%" style="display: block; flex-grow: 1;" frameborder="0" scrolling="no"></iframe></div></blockquote><div><hr></div><p>As you can see, <strong>TaskGroup</strong> achieves the exact same goal as <strong>DispatchGroup</strong> but the structured approach eliminates the enter/leave counter belly dance, and <strong>await</strong> replaces the callback entirely, making the code read easily like synchronous code.<br><br>If you find modern concurrency super confusing like me, let&#8217;s start with these <strong>WWDC</strong> videos first:</p><p>- <a href="https://developer.apple.com/videos/play/wwdc2021/10254">WWDC21 - Swift concurrency: Behind the scenes</a><br>- <a href="https://developer.apple.com/videos/play/wwdc2022/110350">WWDC22 - Visualize and optimize Swift Concurrency</a><br>- <a href="https://developer.apple.com/videos/play/wwdc2023/10170">WWDC23 - Beyond the basics of structured concurrency</a><br><br></p>]]></content:encoded></item><item><title><![CDATA[Adapter Pattern in Swift]]></title><description><![CDATA[Adapt to The World]]></description><link>https://emredegirmenci.substack.com/p/adapter-pattern-in-swift</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/adapter-pattern-in-swift</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sun, 03 May 2026 15:27:28 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!6z84!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The Adapter Pattern, converts the protocol of a class into another protocol the clients expect. Adapter lets classes to work together that couldn&#8217;t otherwise, because of incompatible protocols. The intention is, not change the underlying behavior, not remove behavior, not additional behavior but just adapt something. It&#8217;s not complicated, right &#128565;&#8205;&#128171;<br><br>Imagine you are in the UK/UAE (3-pin, rectangular prongs on the wall) with your EU (2-pin) MacBook charger and your battery drained. You go to the store to buy an adapter which adapts your MacBook charger plug into that 3-pin rectangular prongs socket on the wall. <br><br>In this imagination; </p><p>- <strong>the adaptee</strong> <strong>(UK wall socket)</strong> has the incompatible interface <strong>the client</strong> <strong>(MacBook charger)</strong> can&#8217;t plug directly,<br>- your MacBook charger is <strong>the client</strong> that has the protocol it was built with EU 2-pin, <br>- <strong>the adapter </strong>that we bought from the store, sits in between, translating one protocol to the other without changing either side&#8217;s behavior</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!6z84!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6z84!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 424w, https://substackcdn.com/image/fetch/$s_!6z84!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 848w, https://substackcdn.com/image/fetch/$s_!6z84!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 1272w, https://substackcdn.com/image/fetch/$s_!6z84!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!6z84!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png" width="506" height="444.7550432276657" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:610,&quot;width&quot;:694,&quot;resizeWidth&quot;:506,&quot;bytes&quot;:161938,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196309763?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!6z84!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 424w, https://substackcdn.com/image/fetch/$s_!6z84!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 848w, https://substackcdn.com/image/fetch/$s_!6z84!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 1272w, https://substackcdn.com/image/fetch/$s_!6z84!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1107652c-554e-4d6f-811a-fe4af1c78618_694x610.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Here's the Swift code demonstrated on the plug analogy. UK wall socket as Adaptee, MacBook EU charger as Client, and the travel adapter as the Adapter:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;d70b5f5f-6ba5-43ae-aab3-06bd50eaab46&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// MARK: - Adaptee
// The UK wall socket &#8212; it only has UK 3-pin
 
class UKWallSocket {
    func provideUKPower() -&gt; String {
        return "&#9889;&#65039; 240V via UK 3-pin socket"
    }
}
</code></pre></div><p>The MacBook charger expects EU 2-pin power and it only works with <strong>EUPowerProvider</strong>. It has <strong>no idea</strong> whether the power comes from an <strong>EU socket</strong><br>or a <strong>UK socket</strong> adapted via <strong>TravelAdapter</strong>. MacBookCharger depends on the <strong>protocol</strong> (EUPowerProvider), not the concrete adapter. This keeps it decoupled.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;b91cac5a-a61a-4f02-98bd-c768960558b5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// MARK: - Client Protocol
// The MacBook charger expects EU 2-pin power
 
protocol EUPowerProvider {
    func provideEUPower() -&gt; String
}

// MARK: - Client
 
class MacBookCharger {
    
    private let powerProvider: EUPowerProvider
 
    init(powerProvider: EUPowerProvider) {
        self.powerProvider = powerProvider
    }
 
    func charge() {
        let power = powerProvider.provideEUPower()
        print("MacBook charging... %\(power)")
    }
}</code></pre></div><p>The travel <strong>adapter</strong> sits <strong>between the two</strong>. It plugs into the UK socket <strong>(Adaptee)</strong> and<br>exposes the EU interface the MacBook charger <strong>(Client)</strong> expects. TravelAdapter <strong>wraps</strong> the <strong>adaptee(UKWallSocket)</strong> via <strong>composition (not inheritance)</strong>, which is the preferred Swift approach.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;cbb41477-3a99-4a70-974c-c5a789823a65&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// MARK: - Adapter
 
class TravelAdapter: EUPowerProvider {
 
    private let ukSocket: UKWallSocket
 
    init(ukSocket: UKWallSocket) {
        self.ukSocket = ukSocket
    }
 
    func provideEUPower() -&gt; String {
        let ukPower = ukSocket.provideUKPower()
        return "&#128268; Adapted to EU 2-pin -&gt; \(ukPower)"
    }
}</code></pre></div><p>Doing so, the UK socket's behavior is <strong>unchanged</strong> and the adapter only translates, exactly as the pattern intends.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;2039b4c1-9536-4d2a-b8a5-0419a0ff41dc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">// MARK: - Usage
 
let ukSocket = UKWallSocket() // Adaptee
// Plug adapter into the ukSocket
let adapter = TravelAdapter(ukSocket: ukSocket) // Adapter
// Plug MacBook charger into the adapter
let macCharger = MacBookCharger(powerProvider: adapter) // Client
 
macCharger.charge()
// MacBook charging... &#128268; Adapted to EU 2-pin -&gt; &#9889;&#65039; 240V via UK 3-pin socket</code></pre></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!oV4t!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!oV4t!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 424w, https://substackcdn.com/image/fetch/$s_!oV4t!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 848w, https://substackcdn.com/image/fetch/$s_!oV4t!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 1272w, https://substackcdn.com/image/fetch/$s_!oV4t!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!oV4t!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png" width="1400" height="552" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4092d949-d860-4f34-b2fc-724c20414550_1400x552.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:552,&quot;width&quot;:1400,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:248290,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196309763?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!oV4t!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 424w, https://substackcdn.com/image/fetch/$s_!oV4t!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 848w, https://substackcdn.com/image/fetch/$s_!oV4t!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 1272w, https://substackcdn.com/image/fetch/$s_!oV4t!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4092d949-d860-4f34-b2fc-724c20414550_1400x552.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><div><hr></div><h3>Conclusion</h3><p>In this article, I explained how the Adapter Pattern is used to make two classes with incompatible APIs work together. I demonstrated how to define an adapter by creating a class that wraps around the object being adapted, conforming to the interface the client expects.</p><p></p><p>Sources:<br>- <a href="https://www.amazon.com/Design-Patterns-Swift-Adam-Freeman/dp/148420395X">https://www.amazon.com/Design-Patterns-Swift-Adam-Freeman/dp/148420395X</a></p>]]></content:encoded></item><item><title><![CDATA[When “AI Did the Game Center Work Blindly” But Your App Ballooned to ~2× Size]]></title><description><![CDATA[LLMs and agentic coding tools are not bad at doing tasks: wire up Game Center, add the .gamekit file, sync with App Store Connect.]]></description><link>https://emredegirmenci.substack.com/p/when-ai-did-the-game-center-work</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/when-ai-did-the-game-center-work</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Sat, 02 May 2026 14:12:05 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!5DDN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>LLMs and agentic coding tools are not bad at <em><strong>doing</strong></em> tasks: wire up Game Center, add the <em>.gamekit</em> file, sync with App Store Connect. They are easy to specify for what must not happen<em>, </em>for example <strong>shipping developer-only assets inside the customer build</strong>. This post is about one real case: <strong>Walk Mate &#8211; Daily Route Generator</strong>, where Game Center configuration ended up inside the app bundle, bloating thinned iPhone variants from on the order of <strong>~20&#8211;25 MB compressed</strong> to <strong>~120+ MB compressed</strong>.</p><p>The lesson is not <strong>never use LLMs</strong>. It is <strong>don&#8217;t trust the diff until you&#8217;ve validated size, bundle contents, and build configuration</strong>. The same things we should have checked before shipping anything, human or AI-written.</p><div><hr></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5DDN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5DDN!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 424w, https://substackcdn.com/image/fetch/$s_!5DDN!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 848w, https://substackcdn.com/image/fetch/$s_!5DDN!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 1272w, https://substackcdn.com/image/fetch/$s_!5DDN!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5DDN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png" width="444" height="900" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:900,&quot;width&quot;:444,&quot;resizeWidth&quot;:444,&quot;bytes&quot;:131163,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196213669?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!5DDN!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 424w, https://substackcdn.com/image/fetch/$s_!5DDN!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 848w, https://substackcdn.com/image/fetch/$s_!5DDN!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 1272w, https://substackcdn.com/image/fetch/$s_!5DDN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9353f539-abaf-48c4-9184-a57352a3c50e_444x900.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h3><br><br>What went wrong</h3><p>Apple&#8217;s <strong>Game Center</strong> integration in modern Xcode often uses a <em>GameCenterResources.gamekit</em> package: JSON plus optional localized images, <strong>Pull / Push</strong> with App Store Connect. That file belongs in the project for authoring and sync.</p><p>What it typically does not need, for a standard <strong>achievement</strong> setup, is to sit in <strong>Copy Bundle Resources</strong> for your <strong>Release</strong> app target. <strong>`GameKit` loads achievement metadata and art from Apple&#8217;s services</strong> when your app uses the right IDs. Much of that art lives on Apple&#8217;s CDN (you will see <strong>`mzstatic.com` URLs</strong> inside <strong>`gameCenterResources.json`</strong>). If some locales instead use <strong>relative paths</strong> (for example <strong>`da/AchievementImage-&#8230;.png`</strong>), those files are candidates to be <strong>bundled</strong>. If the <strong>entire `.gamekit`</strong> tree is copied into the app, <strong>every user pays</strong> for that download and disk use.</p><p>In <a href="https://apple.co/4mz7vev">Walk Mate</a>, the package had been wired like ordinary app resources including many megabytes of achievement images. It had also landed in <strong>more than one target</strong> (including an extension), where it had no runtime purpose.</p><p><strong>That&#8217;s the edge case:</strong> Game Center is configured with only what must ship in the IPA should ship.</p><h3>What I changed</h3><ol><li><p><strong>GameCenterResources.gamekit</strong> was removed from <strong>Copy Bundle Resources</strong> for the main iOS app and the notification service extension (it should never have been duplicated there).</p></li><li><p>The <strong>`.gamekit` bundle stays in the repository</strong> under version control so you can still use <strong>Xcode &#8594; (Game Center editor) &#8230; &#8594; Pull from / Push to App Store Connect</strong>; you simply stop embedding that package in the built product shipped to users.</p></li><li><p><strong>gameCenterResources.json </strong>clarified the mechanics: many locales referenced <strong>CDN URLs, Localization </strong>entries used <strong>local relative paths,</strong> which is how multi-megabyte PNGs became part of the payload while other languages did not!</p></li></ol><p>After a clean <strong>Archive</strong>, the <strong>App Thinning Size Report</strong> moved from roughly <strong>~125&#8211;130 MB compressed</strong> for representative iPhone/iPad variants <strong>down to</strong> roughly <strong>~23&#8211;26 MB compressed</strong> &#128512; for comparable slices, a drop on the order of <strong>~100 MB</strong> of avoidable bundle weight, consistent with removing the mistaken resource package. &#129320;<br></p><h3>How to check size</h3><p>After <strong><a href="https://help.apple.com/xcode/mac/current/#/devf37a1db04">Xcode &#8594; Product &#8594; Archive</a>, </strong>use the workflow that produces <strong>App Thinning </strong>analysis from <strong>Xcode Organizer </strong>when working with an App Store archive. Open <strong>App Thinning Size Report.txt</strong>.</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!VxbU!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!VxbU!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 424w, https://substackcdn.com/image/fetch/$s_!VxbU!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 848w, https://substackcdn.com/image/fetch/$s_!VxbU!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 1272w, https://substackcdn.com/image/fetch/$s_!VxbU!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!VxbU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png" width="1456" height="215" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:215,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:62027,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196213669?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!VxbU!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 424w, https://substackcdn.com/image/fetch/$s_!VxbU!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 848w, https://substackcdn.com/image/fetch/$s_!VxbU!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 1272w, https://substackcdn.com/image/fetch/$s_!VxbU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2b002a50-bb53-47ed-acd7-5faf0ed924e4_1554x230.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><ul><li><p>Compare <strong>compressed </strong>vs <strong>uncompressed </strong>per variant: <strong>compressed </strong>is<strong> </strong>closest to <strong>download</strong> perception, <strong>uncompressed </strong>reflects expansion for that thinned slice.</p></li><li><p>Keep <strong>two reports </strong>(before / after a change) and diff the <strong>same variant family. </strong>That is a cheap <strong>regression guard </strong>for accidental resource bloat. </p></li></ul><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!AKM4!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!AKM4!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 424w, https://substackcdn.com/image/fetch/$s_!AKM4!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 848w, https://substackcdn.com/image/fetch/$s_!AKM4!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 1272w, https://substackcdn.com/image/fetch/$s_!AKM4!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!AKM4!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png" width="506" height="1030" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1030,&quot;width&quot;:506,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:113337,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/196213669?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!AKM4!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 424w, https://substackcdn.com/image/fetch/$s_!AKM4!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 848w, https://substackcdn.com/image/fetch/$s_!AKM4!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 1272w, https://substackcdn.com/image/fetch/$s_!AKM4!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F396a1e37-cf5b-42d5-8031-b38da741ef7e_506x1030.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><h3>Reducing app size in general</h3><ul><li><p><strong>Judge shipping app size </strong>from<strong> Release/Archive, </strong>not default <strong>Debug Run.</strong></p></li><li><p><strong>Asset catalogs: </strong>correct scales, avoid overweight PNGs where better formats or simplification suffice.</p></li><li><p><strong>Dependencies: </strong>every Swift package and framework has a cost! Periodic review of what links into the main target matters!</p></li><li><p><strong>Extensions: </strong>each target has its own resources rules, so, avoid duplicating large blobs.</p></li><li><p><strong>When prompting an LLM, </strong>state <strong>non-goals </strong>explicitly like: <em>&#8220;<strong>Do not add to Copy Bundle Resources unless runtime-required; verify with Archive + App Thinning diff.&#8221;</strong></em></p></li></ul><h3>Why we shouldn&#8217;t use LLMs blindly</h3><p>Models optimize for edit from your prompt and visible code. For instance, they don&#8217;t automatically enforce your <strong>download size or budget, </strong>the distinction between <strong>authoring / sync assets </strong> and <strong>runtime bundle contents.<br></strong></p><div><hr></div><h3>Conclusion</h3><p>AI can speed things up, -but it&#8217;s anyways your responsibility to triple-check- so you don&#8217;t embed authoring assets nobody needed at runtime &#128517;<br><br><br><br>Source: <a href="https://developer.apple.com/documentation/xcode/reducing-your-app-s-size">https://developer.apple.com/documentation/xcode/reducing-your-app-s-size</a></p>]]></content:encoded></item><item><title><![CDATA[Faster localization using POEditor]]></title><description><![CDATA[Localization is a very important step for mobile applications.]]></description><link>https://emredegirmenci.substack.com/p/faster-localization-using-poeditor</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/faster-localization-using-poeditor</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Thu, 16 Apr 2026 18:45:12 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!_Qh8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Localization is a very important step for mobile applications. Because it makes it possible to reach a wide range of customers. &#127465;&#127472;&#127475;&#127476;&#127467;&#127470;&#127480;&#127466;&#127470;&#127480;&#127477;&#127473;&#127473;&#127483;</p><p>Even almost every people can speak English in Nordic countries, we&#8217;re supporting 7 different Nordic languages in my company.</p><p>We&#8217;re using <strong>POEditor</strong> for faster localization processes.</p><h1><a href="https://poeditor.com">POEditor</a></h1><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!_Qh8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!_Qh8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 424w, https://substackcdn.com/image/fetch/$s_!_Qh8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 848w, https://substackcdn.com/image/fetch/$s_!_Qh8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 1272w, https://substackcdn.com/image/fetch/$s_!_Qh8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!_Qh8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png" width="1362" height="338" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:338,&quot;width&quot;:1362,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:135082,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/194438633?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!_Qh8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 424w, https://substackcdn.com/image/fetch/$s_!_Qh8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 848w, https://substackcdn.com/image/fetch/$s_!_Qh8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 1272w, https://substackcdn.com/image/fetch/$s_!_Qh8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6adbd5ab-5b1c-4ac0-af68-d9e3af60c5e9_1362x338.png 1456w" sizes="100vw" fetchpriority="high"></picture><div></div></div></a></figure></div><p>In this article, I will explain how to add a language, export, and import localization files in a faster way.</p><p>First of all, we have a <strong>String</strong> extension like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;e1bee13d-b178-4097-8f7f-9e5a0bf32b85&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">extension String {
    public func localized(_ bundle: Bundle = .locales) -&gt; String {
        NSLocalizedString(self, bundle: bundle, comment: "")
    }
}

//Usage
class Foo: UIViewController {
    let title = "settings_title".localized()
}</code></pre></div><p>When the new strings are added to the code base,</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;041c8272-a87b-4c83-9e7f-9f65632bae47&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">&#8220;settings_title&#8221;.localized()</code></pre></div><p>then you need to add these new strings to</p><pre><code>Add the string to the English version of Supporting Files/Localizable.strings
<strong>i.e.: </strong>Project_Directory/Project_Name/Sources/Locales/en.lproj/Localizable.strings</code></pre><p>and send to <strong>POEditor</strong> (either manually or automatically). Then someone(licensed translator) does the translation in <strong>POEditor</strong> manually. <br>After that, you should be able to fetch them (either manually or automatically) from <strong>POEditor</strong>.</p><h2>How do we extract/import translations from/to POEditor automatically?</h2><p>We can either manually send the new strings to <a href="http://poeditor.com/">poeditor.com</a> using<br><strong>fastlane translations_extract</strong> <a href="https://fastlane.tools/">fastlane</a> script or <strong>GitLab CI</strong> doing it for us automatically.</p><p>After someone(licensed translator) does the translation in <strong>POEditor</strong> manually.</p><p>After all of that has been done we can run the following command <br><strong>fastlane translations_import</strong> to fetch the new translations and check them into the codebase.</p><h2>Conclusion</h2><p><strong>POEditor</strong> is a great localization tool to increase user experience and save time since you will be waiting for translations for the new strings.</p>]]></content:encoded></item><item><title><![CDATA[iOS Accessibility VoiceOver]]></title><description><![CDATA[What is VoiceOver?]]></description><link>https://emredegirmenci.substack.com/p/ios-accessibility-voiceover</link><guid isPermaLink="false">https://emredegirmenci.substack.com/p/ios-accessibility-voiceover</guid><dc:creator><![CDATA[Emre Degirmenci]]></dc:creator><pubDate>Thu, 16 Apr 2026 18:39:36 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!MEXZ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!MEXZ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!MEXZ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 424w, https://substackcdn.com/image/fetch/$s_!MEXZ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 848w, https://substackcdn.com/image/fetch/$s_!MEXZ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 1272w, https://substackcdn.com/image/fetch/$s_!MEXZ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!MEXZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp" width="1400" height="1054" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1054,&quot;width&quot;:1400,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:48098,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/194437936?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!MEXZ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 424w, https://substackcdn.com/image/fetch/$s_!MEXZ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 848w, https://substackcdn.com/image/fetch/$s_!MEXZ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 1272w, https://substackcdn.com/image/fetch/$s_!MEXZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c57fee2-d535-438e-84b8-9ef9d58898c7_1400x1054.webp 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">https://unsplash.com/photos/1tt7DzXb1WA</figcaption></figure></div><h2>What is VoiceOver?</h2><p>As you guess disabled people use smartphone apps as well. Designing your apps with accessibility in mind helps everyone use them, including people with vision, or hearing disabilities.</p><blockquote><p><em><a href="https://en.wikipedia.org/wiki/VoiceOver">VoiceOver is a screen reader built into Apple Inc.&#8217;s macOS, iOS, tvOS, watchOS, and iPod operating systems. By using VoiceOver, the user can access their Macintosh or iOS device based on spoken descriptions and, in the case of the Mac, the keyboard.</a></em></p></blockquote><h2>Why Accessibility?</h2><p>In my current company, we&#8217;re also supporting Accessibilities, especially VoiceOver. In our e-paper applications, we have <strong>active 2K blind users.</strong></p><ul><li><p>You&#8217;ll reach a larger group.</p></li><li><p>It feels good to know you&#8217;re making a noticeable difference in more people&#8217;s life.</p></li></ul><h2>How to Activate and Use VoiceOver?</h2><p>You can find detailed information about activation and usage of VoiceOver on iPhone on Apple&#8217;s website:<br><a href="https://support.apple.com/tr-tr/guide/iphone/iph3e2e415f/ios">https://support.apple.com/guide/iphone/iph3e2e415f/ios</a></p><ul><li><p><strong>Single-tap</strong> anywhere and VoiceOver will read information from the item&#8217;s accessibility attributes loudly.</p></li><li><p><strong>Single-swipe left or right</strong> and VoiceOver will select the next visible accessibility item and read it loudly.</p></li><li><p><strong>Single-swipe down</strong> to spell the focused item letter-by-letter.</p></li><li><p><strong>Double-tap</strong> to select the specific item.</p></li><li><p><strong>Three-finger-swipe</strong> left or right to navigate forward or backward in a page view.</p></li></ul><p>For the complete list of VoiceOver gestures, check out <a href="https://support.apple.com/guide/iphone/learn-voiceover-gestures-iph3e2e2281/ios">Apple&#8217;s Learn VoiceOver gestures on iPhone</a>. So now you know how VoiceOver works.</p><h2>Accessibility Attributes</h2><p>An accessibility attribute has five properties:</p><p>First of all, you should define the accessibility element of the UI element.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;fd6a9201-ceb5-4ff0-82da-f266679f7424&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">titleLabel.isAccessibilityElement = true</code></pre></div><ol><li><p><strong>accessibilityLabel:</strong> A concise way to identify the control or view.</p></li></ol><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;23e28788-42b9-4294-93c3-34bb0e442ad4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">titleLabel.accessibilityLabel = foo.title</code></pre></div><p>2.<strong>  accessibilityTraits</strong>: These describe the element&#8217;s state or behavior. A cell trait might be <strong>.button</strong>, for example.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;6a2a1e36-c969-47c6-8078-7acf8ed28129&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">cell.accessibilityTraits = .none

cell.accessibilityTraits = .button

cell.accessibilityTraits = .link

cell.accessibilityTraits = .header

cell.accessibilityTraits = .adjustable

cell.accessibilityTraits = .allowsDirectInteraction

cell.accessibilityTraits = .causesPageTurn

cell.accessibilityTraits = .image

cell.accessibilityTraits = .keyboardKey

cell.accessibilityTraits = .notEnabled

cell.accessibilityTraits = .playSound

cell.accessibilityTraits = .searchField

cell.accessibilityTraits = .startsMediaSession

cell.accessibilityTraits = .staticText

cell.accessibilityTraits = .selected

cell.accessibilityTraits = .summaryElement

cell.accessibilityTraits = .tabBar</code></pre></div><p>3.<strong>  accessibilityHint</strong>: Describes the action an element completes. For example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;swift&quot;,&quot;nodeId&quot;:&quot;da644410-3270-4167-b55d-2a837e9a4b97&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-swift">playButton.accessibilityHint = "double_tap_to_pause"
loginCell.accessibilityHint = "double_tap_to_log_out"</code></pre></div><p>4.<strong> accessibilityFrame</strong>: The frame of the element within the screen, in the format of a <strong>CGRect</strong>. VoiceOver speaks the contents of the <strong>CGRect</strong>.</p><p>5<strong>. accessibilityValue</strong>: The value of an element. For example, with a progress bar or a slider, the current value might read: <strong>5 out of 100</strong>.</p><h2>Using the Accessibility Inspector</h2><p>There&#8217;s a tool named <strong>Accessibility Inspector</strong>, which does the following:</p><ul><li><p>Lets you check the accessibility attributes of UI elements in Inspection Mode.</p></li><li><p>Provides live previews of accessibility elements without leaving your app.</p></li><li><p>Supports all platforms including macOS, iOS, watchOS, and tvOS.</p></li></ul><p>To do all of these things, open it in the Xcode menu by navigating to <strong>Xcode &#9656; Open Developer Tool &#9656; Accessibility Inspector</strong>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!iI4O!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!iI4O!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 424w, https://substackcdn.com/image/fetch/$s_!iI4O!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 848w, https://substackcdn.com/image/fetch/$s_!iI4O!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 1272w, https://substackcdn.com/image/fetch/$s_!iI4O!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!iI4O!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp" width="870" height="715" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:715,&quot;width&quot;:870,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:36252,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://emredegirmenci.substack.com/i/194437936?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!iI4O!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 424w, https://substackcdn.com/image/fetch/$s_!iI4O!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 848w, https://substackcdn.com/image/fetch/$s_!iI4O!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 1272w, https://substackcdn.com/image/fetch/$s_!iI4O!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fae6add83-7dc9-4f66-8e19-8db67bfe2df2_870x715.webp 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Conclusion</h2><p>You learned about VoiceOver. You used the Accessibility Inspector to perform audits by scrolling through every accessible element. See you &#128406;&#127996;</p>]]></content:encoded></item></channel></rss>