<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Swiftui on Lessons learned</title><link>https://eghuro.cz/tags/swiftui/</link><description>Recent content in Swiftui on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Fri, 30 Oct 2026 14:38:00 +0100</lastBuildDate><atom:link href="https://eghuro.cz/tags/swiftui/index.xml" rel="self" type="application/rss+xml"/><item><title>SwiftUI large title disappears after tapping the selected tab again</title><link>https://eghuro.cz/posts/swiftui-large-title-global-appearance/</link><pubDate>Fri, 30 Oct 2026 14:38:00 +0100</pubDate><guid>https://eghuro.cz/posts/swiftui-large-title-global-appearance/</guid><description>&lt;p&gt;The Settings large title disappeared after tapping the already-selected tab again. Its space stayed empty; the small title still appeared when scrolling, and the accessibility check passed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;titleBefore=true titleAfter=true&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An opaque navigation-bar background was painting over the title. The eventual repair removed a screen modifier&amp;rsquo;s writes to &lt;code&gt;UINavigationBar.appearance()&lt;/code&gt;. Hiding the affected screen&amp;rsquo;s bar background was an intermediate workaround that introduced a font regression.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-title-still-existed"&gt;The title still existed &lt;/h2&gt;&#10;&lt;p&gt;The prototype varied the tab view, loading state, scroll-view wrapper, and swipe-back helper. Its accessibility existence checks stayed green. Adding the app&amp;rsquo;s opaque navigation-bar appearance also produced green checks, even though the title vanished visually.&lt;/p&gt;&#10;&lt;p&gt;Counting dark pixels in the prototype&amp;rsquo;s title region exposed the difference:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Appearance&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;After first tap&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;After re-tap&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Default&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Opaque&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Those are diagnostic counts from one fixed light-background rendering, not a portable visibility threshold. They contradicted the existence assertion directly.&lt;/p&gt;&#10;&lt;p&gt;Before-and-after hierarchy dumps from the running app showed the same title view moving from the navigation bar into the scroll view. The bar&amp;rsquo;s opaque background then covered it. Initially, the screen displayed a loader without a scroll view, so UIKit built the title inside the bar. Re-tapping the selected tab triggered the transition that exposed the problem on the investigated simulator.&lt;/p&gt;&#10;&lt;p&gt;This establishes a rendering mechanism in that environment. It does not establish identical title placement on every supported runtime.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-local-modifier-changed-later-navigation-bars"&gt;A local modifier changed later navigation bars &lt;/h2&gt;&#10;&lt;p&gt;The offending modifier intended to remove a divider and set one screen&amp;rsquo;s background. Its essential mistake looked like this generalized fragment inside a SwiftUI &lt;code&gt;.onAppear&lt;/code&gt; closure:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UINavigationBarAppearance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configureWithOpaqueBackground&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backgroundColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secondarySystemBackground&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shadowColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UINavigationBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;standardAppearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appearance&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UINavigationBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;scrollEdgeAppearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appearance&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The closure belongs to a screen; the &lt;a&#10; href="https://developer.apple.com/documentation/uikit/uiappearance"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;appearance proxy&lt;/a&gt;&#10; configures all instances of that class. UIKit applies appearance changes when a view enters a window. A different tab&amp;rsquo;s later navigation bar can inherit those settings, so screen order becomes part of the bug&amp;rsquo;s setup.&lt;/p&gt;&#10;&lt;p&gt;The repair deleted this global writer. The calling screens already declared their backgrounds through SwiftUI&amp;rsquo;s per-screen toolbar styling; the investigated runtimes did not need a replacement divider-hiding implementation. Their existing snapshots passed without re-recording. Appearance save-and-restore workarounds around a system-presented screen went away with the leak.&lt;/p&gt;&#10;&lt;p&gt;A focused test supplied stronger evidence than the original title lookup: show a styled screen, then create another navigation bar and check its background. It failed before the deletion because the next bar inherited a background, then passed afterward. Separate checks guarded the calling screen&amp;rsquo;s color and the next large title&amp;rsquo;s font.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-one-line-hotfix-was-reversed"&gt;Why the one-line hotfix was reversed &lt;/h2&gt;&#10;&lt;p&gt;The initial workaround used &lt;code&gt;.toolbarBackground(.hidden, for: .navigationBar)&lt;/code&gt; on Settings. It kept the prototype&amp;rsquo;s title visible under the opaque appearance.&lt;/p&gt;&#10;&lt;p&gt;The attempted app snapshot never reproduced the original disappearance. The real screen reloaded into a spinner first, unlike the prototype&amp;rsquo;s scroll-view-first case. Capturing the loaded window still showed the title without the fix. We dropped that test; it couldn&amp;rsquo;t serve as a regression guard.&lt;/p&gt;&#10;&lt;p&gt;Later checks found that hiding the background also made the large title use the system font instead of the intended custom font. A snapshot cropped to the bar failed with that modifier present and passed after its removal. Once the global opaque write was gone, the workaround no longer had a reason to stay.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-the-interaction-not-just-the-label"&gt;Verify the interaction, not just the label &lt;/h2&gt;&#10;&lt;p&gt;Builds and broad test plans passed after the cleanup. Final manual checks in the running app remained open, and older supported runtimes were untested. The font snapshot and next-bar test do not close that gap.&lt;/p&gt;&#10;&lt;p&gt;For release verification:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Visit the screens that previously wrote global appearance, then open Settings. Repeat with a different screen order.&lt;/li&gt;&#10;&lt;li&gt;Tap the selected Settings tab again. Capture the title before and after, then scroll through its large-to-inline transition.&lt;/li&gt;&#10;&lt;li&gt;Check actual title pixels and contrast against the background. Use the hierarchy to explain occlusion; accessibility existence, or &lt;code&gt;isHittable&lt;/code&gt; alone, is not universal proof that text is painted visibly.&lt;/li&gt;&#10;&lt;li&gt;Repeat on supported runtimes, checking pushed screens&amp;rsquo; bar styling too.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Audit screen-level &lt;code&gt;UINavigationBar.appearance()&lt;/code&gt; writes before adding another visibility workaround.&lt;/p&gt;&#10;</description></item><item><title>Snapshot precision 0.99 let a wrong text line pass all seven screen tests</title><link>https://eghuro.cz/posts/snapshot-precision-099-hides-the-field-under-test/</link><pubDate>Tue, 13 Oct 2026 17:04:00 +0200</pubDate><guid>https://eghuro.cz/posts/snapshot-precision-099-hides-the-field-under-test/</guid><description>&lt;p&gt;A code review claimed that a list screen&amp;rsquo;s snapshot tests couldn&amp;rsquo;t fail on the very line they were written to pin: a small gray &amp;ldquo;Location&amp;rdquo; line under each search result. To check, I broke that line on purpose. The rows got the container&amp;rsquo;s short name instead of its full name, so every location line rendered the wrong text. Then I ran the suite:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenDeletedItemsShowTheirOrigin() passed after 0.296 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSearchResultsShowTheirLocation() passed after 0.708 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test run with 7 tests in 1 suite passed after 0.793 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;All seven passed against the broken code. If your snapshot tests record a whole screen at &lt;code&gt;.device&lt;/code&gt; layout with a &lt;code&gt;precision&lt;/code&gt; below 1.0, they may be just as blind.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;The project used &lt;a&#10; href="https://github.com/pointfreeco/swift-snapshot-testing"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;swift-snapshot-testing&lt;/a&gt;&#10; 1.19.2, with Swift Testing on the iOS simulator. A shared defaults file sets both tolerances:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The two gates do different jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;perceptualPrecision&lt;/code&gt; decides whether a single pixel counts as different. 0.90 absorbs anti-aliasing drift between developer Macs.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;precision&lt;/code&gt; decides what fraction of pixels must match. 0.99 lets 1% of the image differ by any amount.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The 0.99 was there to unblock CI: one render drifted by about 3,600 pixels between machines. The comment next to it even warned that it was &amp;ldquo;wide enough to hide a small real regression&amp;rdquo;. Every screen test used it anyway.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-arithmetic"&gt;The arithmetic &lt;/h2&gt;&#10;&lt;p&gt;An iPhone 17 Pro device frame is 1179×2556 pixels.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1179 × 2556 = 3,013,524 px&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1% of that = 30,135 px may differ freely&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wrong location = ~900 px actually differed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A 1% budget sounds strict. On a full screen it&amp;rsquo;s about a 58-point square of arbitrary change, more than all the location lines on the screen combined. The test was recorded correctly and passed honestly. It just had no way to fail on the thing in its name.&lt;/p&gt;&#10;&#10;&lt;h2 id="measuring-the-mutation"&gt;Measuring the mutation &lt;/h2&gt;&#10;&lt;p&gt;To size the gate, record the mutated renders, diff them against the committed references, then restore the references. A few lines of Pillow give a rough count (a pixel counts if any channel moves by more than 25):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;sys&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RGB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RGB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;.2%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;TEST_RUNNER_SNAPSHOT_TESTING_RECORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all xcodebuild &lt;span class="nb"&gt;test&lt;/span&gt; ... -only-testing:MyTests/ListScreenSnapshotTests&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show HEAD:path/to/reference.png &amp;gt; /tmp/head.png&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 pxdiff.py /tmp/head.png path/to/reference.png&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git checkout -- path/to/__Snapshots__ &lt;span class="c1"&gt;# put the real references back&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...DarkAppearance...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...SearchResultsAtTheDynamicTypeCap...: 0.14% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...SearchResultsShowTheirLocation...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...DeletedItemsShowTheirOrigin...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The smallest real defect was 0.03%, about 900 pixels, so the budget had to sit well below that. 0.9999 allows 301.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Give the screen suite its own precision and write the numbers down where the next person will see them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SnapshotDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// A device frame is 1179×2556 px, so the default 1% budget is 30,135 px: wider than every&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// Location line on the screen together. The wrong name moves about 900 px; this leaves a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// 301 px budget, while `perceptualPrecision` still absorbs anti-aliasing drift per pixel.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9999&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then rerun with the mutation still in place. It has to go red:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenDeletedItemsShowTheirOrigin() failed after 0.594 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSortMenuOpen() passed after 0.751 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenBrowsingTheRoot() passed after 0.807 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenSearchResultsAtTheDynamicTypeCap() failed after 1.220 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenSearchResultsShowTheirLocation() failed after 1.618 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSearchWithNoResults() passed after 1.659 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenDarkAppearance() failed after 2.036 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The four cases that draw location or origin lines failed. The three cases without them passed, which is also correct. After reverting the mutation, all seven passed again at 0.9999.&lt;/p&gt;&#10;&lt;p&gt;A 301-pixel budget is tight, and I verified it on one machine. If cross-machine drift trips it, look at the drift before you loosen the number.&lt;/p&gt;&#10;&#10;&lt;h2 id="shrink-the-frame-when-you-can"&gt;Shrink the frame when you can &lt;/h2&gt;&#10;&lt;p&gt;For a single component, a tight frame beats a tight precision. The same project measured a toolbar button&amp;rsquo;s enabled-to-disabled color change at 1,808 pixels, or 0.06% of a device frame. Rendered at 140×60 with &lt;code&gt;layout: .fixed(width:height:)&lt;/code&gt;, those 1,808 pixels became 2.4% of the image, well past a 1% budget. Keep &lt;code&gt;.device&lt;/code&gt; for tests that are really about the whole screen, and size their precision from the smallest change they claim to catch.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;precision&lt;/code&gt; is a fraction of the image, so a fixed value forgives more pixels as the frame grows. Before you trust a green snapshot, break the thing it&amp;rsquo;s named after, count the changed pixels, and compare that with &lt;code&gt;(1 - precision) × width × height&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Pick one snapshot test in your suite, break its subject on purpose, and see whether it goes red.&lt;/p&gt;&#10;</description></item></channel></rss>