<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>twocentstudios</title>
    <description>A coding blog covering iOS, Swift, and other programming topics.</description>
    <link>https://twocentstudios.com/</link>
    <atom:link href="https://twocentstudios.com/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Sun, 01 Feb 2026 00:12:37 -0600</pubDate>
    <lastBuildDate>Sun, 01 Feb 2026 00:12:37 -0600</lastBuildDate>
    <generator>Jekyll v3.9.3</generator>
    
      <item>
        <title>Print Design Mindset to App Design Mindset</title>
        <description>&lt;h2 id=&quot;the-print-design-era&quot;&gt;The print design era&lt;/h2&gt;

&lt;p&gt;There was a time when iOS &lt;strong&gt;app design&lt;/strong&gt; wasn’t all that far off from &lt;strong&gt;print design&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For the first couple years of the iPhone SDK (2008-2011):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;All iPhones had 320 width by 480 height screens.&lt;/li&gt;
  &lt;li&gt;All iPhone screens were &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@1x&lt;/code&gt; non-retina.&lt;/li&gt;
  &lt;li&gt;The platform design language was highly skeuomorphic and rewarded delicately crafted Photoshop assets.&lt;/li&gt;
  &lt;li&gt;iOS &lt;em&gt;did&lt;/em&gt; support localization but not for RTL languages and the launch country list was relatively English-speaking (and it was never &lt;em&gt;required&lt;/em&gt; for App Store listing).&lt;/li&gt;
  &lt;li&gt;Accessibility features that dynamically altered app content visually did not exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-iphones-early-era-320pt.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;iPhone, iPhone 3G, iPhone 4, and iPhone 5 — all 320 points wide&quot; title=&quot;iPhone, iPhone 3G, iPhone 4, and iPhone 5 — all 320 points wide&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iPhone, iPhone 3G, iPhone 4, and iPhone 5 — all 320 points wide&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Sure, there were dynamic elements like buttons with highlighted, disabled, and selected states. Sure, there were scrolling screens. But the overall workflow of app design to app development was taking a Photoshop mockup and recreating it piece by piece in the SDK.&lt;/p&gt;

&lt;p&gt;Until &lt;a href=&quot;https://www.sketch.com/&quot;&gt;Sketch&lt;/a&gt; gained some prominence in the 2013-2015 era, Photoshop was still the most popular tool for less technical designers to work in before artifact hand off.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-sketch-ios-artboard-presets.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;Sketch app showing iOS device artboard presets including iPhone 6, iPhone 5, and iPad sizes&quot; title=&quot;Sketch app showing iOS device artboard presets including iPhone 6, iPhone 5, and iPad sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Sketch app showing iOS device artboard presets including iPhone 6, iPhone 5, and iPad sizes&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The first cracks in the print design workflow surprisingly started with the sudden differentiation between points and pixels with the iPhone 4 in 2010. Even then, that didn’t necessitate a mindset change away from print design, just a few configuration changes in Photoshop and some extra work come asset export time.&lt;/p&gt;

&lt;p&gt;It wasn’t until the iPhone 6 in 2014 that designers (and developers) could no longer assume devices had the same 320 point width. As an iOS developer, you could get away with hard-coding dimensions for your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIView&lt;/code&gt;s for a very long time! (Assuming you supported only landscape &lt;em&gt;OR&lt;/em&gt; portrait orientation).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-iphones-various-sizes.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;iPhone 6 (375pt), iPhone X (375pt), iPhone 13 (390pt), and iPad Air (768pt)&quot; title=&quot;iPhone 6 (375pt), iPhone X (375pt), iPhone 13 (390pt), and iPad Air (768pt)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iPhone 6 (375pt), iPhone X (375pt), iPhone 13 (390pt), and iPad Air (768pt)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And if varying screen widths weren’t enough, the iPhone X in 2017 broke the assumption that the screen was even a simple rectangle. The notch, rounded corners, and the home indicator meant designers could no longer treat the canvas as a predictable bordered frame.&lt;/p&gt;

&lt;h2 id=&quot;the-complexity-catches-up&quot;&gt;The complexity catches up&lt;/h2&gt;

&lt;p&gt;I’ll skip to present day to say that we’re long past the point that most app designers can, with a good conscience, keep a print design mindset when doing visual design for mobile.&lt;/p&gt;

&lt;p&gt;The complexity grew, and luckily for iOS developers, the tooling provided by Apple to rein in that complexity has slowly caught up:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For view layout, we have stacks, content margins, size classes, line limits, leading/trailing instead of left/right.&lt;/li&gt;
  &lt;li&gt;For Dynamic Type (user adjustable font sizes), we have semantic fonts like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.body&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.headline&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.largeTitle&lt;/code&gt;. We have the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Scaled&lt;/code&gt; property wrapper in SwiftUI. We have &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIFontMetrics&lt;/code&gt; for scaling non-system fonts.&lt;/li&gt;
  &lt;li&gt;For Dark Mode, we have semantic colors like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.label&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.background&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.secondary&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.blue&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.tint&lt;/code&gt;, and asset catalogs.&lt;/li&gt;
  &lt;li&gt;For localization, we have auto-updating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; files, Double-Length pseudolanguage, RTL pseudolanguage, asset catalog localization, plural strings, and localization aware formatters.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-hig-semantic-label-colors.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;HIG semantic label colors adapt automatically across appearances&quot; title=&quot;HIG semantic label colors adapt automatically across appearances&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;HIG semantic label colors adapt automatically across appearances&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-hig-system-colors.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;HIG system colors vary across light mode, dark mode, and increased contrast&quot; title=&quot;HIG system colors vary across light mode, dark mode, and increased contrast&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;HIG system colors vary across light mode, dark mode, and increased contrast&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/print-design-hig-dynamic-type-sizes.jpg&quot; width=&quot;&quot; height=&quot;&quot; alt=&quot;HIG Dynamic Type defines seven non-accessibility size categories with semantic text styles&quot; title=&quot;HIG Dynamic Type defines seven non-accessibility size categories with semantic text styles&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;HIG Dynamic Type defines seven non-accessibility size categories with semantic text styles&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, there’s a similar time lag (measured in years) between SDK support and true developer tooling support that exists between developer tooling support and designer tooling support for all these features. There probably always will be. Users will adopt them and demand them faster than our tools will support them.&lt;/p&gt;

&lt;h2 id=&quot;the-mindset-shift&quot;&gt;The mindset shift&lt;/h2&gt;

&lt;p&gt;But going back to print design.&lt;/p&gt;

&lt;p&gt;I love (well, maybe love/hate) designing apps, but what I love more is working with talented designers. There’s something about the design field that draws individuals with a tenacious eye for detail and an equally fierce demand for seeing their work reflected with pixel perfection. And most of the time, I would interpret this desire as driven by empathy for the user and not selfishness. They know users recognize and deserve beauty. In other words, their intentions are good.&lt;/p&gt;

&lt;p&gt;The trap I’ve seen some of these designers fall into is assuming it’s enough to have empathy for &lt;em&gt;a&lt;/em&gt; user and not &lt;em&gt;all&lt;/em&gt; users of their designs. In the early iPhone era, 100% of users would see the same screen pixel-for-pixel that the designer created in their Photoshop document. In 2026, the percent of users that see a screen exactly as it was designed is ballpark closer to single digit percentages, if that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are simply too many variables that affect the final visual design a user sees&lt;/strong&gt; when they open your app. Too many to optimize for pixel accuracy with a print design mindset. Dozens of screen sizes. Dozens of languages. Dark Mode. Near a dozen Dynamic Type settings. Bold text. Button shapes. Virtual home buttons. A virtual cornucopia of more esoteric accessibility options. And on iPad, not even fixed screen sizes thanks to Slide Over, Split View, and Stage Manager.&lt;/p&gt;

&lt;p&gt;And that’s not even digging into the impedance mismatches baked into the design tools themselves: system fonts displaying a little bit smaller on device than in Figma; color spaces not matching; static assets losing fidelity during export.&lt;/p&gt;

&lt;p&gt;I’ve worked alongside many incredibly talented designers over the years. Almost all of them sooner or later made the jump from print design mindset to app design mindset. It took a lot of frustrating (on both sides!) conversations.&lt;/p&gt;

&lt;p&gt;At the end of the day, as a developer, I knew the device was the final arbiter of truth. The painstakingly crafted mockups only reflected reality for the brief period of time before the app went live on App Store, and after that they were vestigial colored rectangles, decaying with a half life in the order of days not weeks. The same way that multi-page product spec lovingly crafted by the product manager is lost to the abyss of Google Docs at the end of the sprint.&lt;/p&gt;

&lt;p&gt;I knew the signs of the mindset shift, and they all came like manna from heaven. I’d start to receive mockups with loading states leading to the happy path. I’d get error messages with &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/&quot;&gt;HIG&lt;/a&gt;-respectful copywriting. I’d get sidecar notes in the margins pointing out fixed and flexible dimensions. I’d get line limits for text. I’d see table cells with long and short user names. With empty and populated avatar images. The apps were starting to show signs of &lt;em&gt;being apps&lt;/em&gt; before I’d actually turned them into living &amp;amp; breathing apps.&lt;/p&gt;

&lt;p&gt;More importantly, I would &lt;em&gt;stop&lt;/em&gt; receiving design review feedback about marketing copy not line breaking at the exact same words as the mockup. About using a custom alert view instead of the system one.&lt;/p&gt;

&lt;p&gt;The designers were now channeling their intrinsic motivation and eye for detail that previously went into pixel perfection into ensuring the maximum number of users with any combination of device settings would still be able to use the damn app to get their task accomplished. And hopefully be able to appreciate the fact the app looked and felt nice while they were getting things done.&lt;/p&gt;

&lt;p&gt;The inter-department friction would melt away. We’d ship faster even though we were supporting more design variables than ever before. After all, designers were taking on more of the work I’d previously been doing turning screen after lifeless screen into constraints and states and conditionals. I had more brain bandwidth to double check their assumptions, cover more edge cases, explore architectures that would keep the code from rotting, experiment with animations, and still have time to pair with them on polish. And even more-so, develop abstractions to make this work faster and easier in the future.&lt;/p&gt;

&lt;p&gt;When you make the foundational work easy, you have more time left for whimsy. Which is more fun for both the app makers and the app users.&lt;/p&gt;

&lt;h2 id=&quot;making-the-shift&quot;&gt;Making the shift&lt;/h2&gt;

&lt;p&gt;So how can a designer with a print mindset adopt an app design mindset?&lt;/p&gt;

&lt;p&gt;Half the battle is simply recognizing the differences between the two. Giving each a name. Recognizing the signs and fighting your instincts for pixel perfection.&lt;/p&gt;

&lt;p&gt;From there, I can offer a few suggestions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Pairing with developers who &lt;em&gt;care&lt;/em&gt; about accessibility, design systems, the human interface guidelines, and most of all, users.&lt;/li&gt;
  &lt;li&gt;Immersing in Apple-sanctioned resources like the &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/&quot;&gt;HIG&lt;/a&gt;, &lt;a href=&quot;https://developer.apple.com/videos/&quot;&gt;WWDC videos&lt;/a&gt;, and learning how to search, read, and understand the developer &lt;a href=&quot;https://developer.apple.com/documentation&quot;&gt;documentation&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;Immediately learning the avant-garde features of design tools that lag behind the native developer tools.&lt;/li&gt;
  &lt;li&gt;Following the unicorn designer/developer hybrid folks that work in and share wisdom from both sides of the aisle.&lt;/li&gt;
  &lt;li&gt;Gaining enough proficiency with developer (and AI?) tools to become dangerous (or just be able to update a color value or knock out a quick interactive prototype).&lt;/li&gt;
  &lt;li&gt;Visualizing apps not just as a collection of snapshots of happy path screens, but a rich journey of pushes, pops, loads, errors, mistaps, drags, interruptions, and everything in between.&lt;/li&gt;
  &lt;li&gt;Do the painful work of watching over a user’s shoulder as they tap through &lt;em&gt;your&lt;/em&gt; app on &lt;em&gt;their&lt;/em&gt; 8 year old iPhone with &lt;em&gt;their&lt;/em&gt; settings and &lt;em&gt;their&lt;/em&gt; cracked screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cheers to many more happy and healthy designer 🤝 developer partnerships in the years to come.&lt;/p&gt;
</description>
        <pubDate>Thu, 29 Jan 2026 14:26:08 -0600</pubDate>
        <link>https://twocentstudios.com/2026/01/29/print-design-mindset/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2026/01/29/print-design-mindset/</guid>
        
        <category>apple</category>
        
        <category>design</category>
        
        <category>ios</category>
        
        <category>commentary</category>
        
        
      </item>
    
      <item>
        <title>Introducing Eki Bright 2.0</title>
        <description>&lt;p&gt;Eki Bright 2.0 is available today.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-app-icon.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Eki Bright app icon&quot; title=&quot;Eki Bright app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Bright app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Eki Bright is the fastest way to access station timetables for the Tokyo metropolitan area.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://apps.apple.com/app/%E9%A7%85%E3%83%96%E3%83%A9%E3%82%A4%E3%83%88/id6504702463&quot;&gt;Download Eki Bright on the App Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The original design for the Eki Bright app evolved out of the initial use case of configuring Widgets, but steadily grew to support searching, nearby stations, DIY routing, and Live Activities.&lt;/p&gt;

&lt;p&gt;For Eki Bright 2.0, the goal was to optimize the app for how I’d &lt;em&gt;actually&lt;/em&gt; been using it and how its users had told me they use it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-home-evolution.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Home screen evolution from v1.0 to v2.0&quot; title=&quot;Home screen evolution from v1.0 to v2.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Home screen evolution from v1.0 to v2.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The main focus is now nearby stations, which I found to be 95% of my usage over the year since its first release. In many cases, you’ll see the exact station timetable you want in less than a second after opening the app. Other nearby stations are a quick scroll at your resting thumb position, and order based on view history and distance from your current location.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-nearby-scroll.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Nearby stations screen with scrolling&quot; title=&quot;Nearby stations screen with scrolling&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Nearby stations screen with scrolling&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;From here, it’s super easy to start a new DIY route by tapping your departure train time, then tapping the route icon of your arrival station.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-diy-route-create.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Creating a DIY route by tapping departure time and arrival station&quot; title=&quot;Creating a DIY route by tapping departure time and arrival station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Creating a DIY route by tapping departure time and arrival station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The route bar from v1.x has moved to the native tab bar accessory, which automatically expands and collapses.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-route-bar.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Route bar in the tab bar accessory&quot; title=&quot;Route bar in the tab bar accessory&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Route bar in the tab bar accessory&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;DIY routes now have a full screen view in Eki Bright 2.0. You can see the full route and transfers, and view all stops along your route.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-route-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Full screen route view with expanded stops list on the right&quot; title=&quot;Full screen route view with expanded stops list on the right&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Full screen route view with expanded stops list on the right&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It’s easy to compare adjacent departure times. For example, to check whether the next departing local train will actually reach your destination &lt;em&gt;after&lt;/em&gt; a later departing express train.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-route-compare-departures.gif&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Comparing local and express departure times, the express arrives 7 minutes earlier than the local that departs 3 minutes earlier&quot; title=&quot;Comparing local and express departure times, the express arrives 7 minutes earlier than the local that departs 3 minutes earlier&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Comparing local and express departure times, the express arrives 7 minutes earlier than the local that departs 3 minutes earlier&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Eki Bright remembers your most used route patterns and suggests them on the route screen based on your current location. After a few trips, you don’t need to manually create DIY routes anymore.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-suggested-routes.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Suggested routes based on location and history, departure times updating live&quot; title=&quot;Suggested routes based on location and history, departure times updating live&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Suggested routes based on location and history, departure times updating live&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The Live Activity now updates more reliably in the background throughout your trip.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity on the lock screen&quot; title=&quot;Live Activity on the lock screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity on the lock screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My personal favorite new addition to Eki Bright 2.0 is the station relative direction arrow indicators on the station detail screens. This makes it much easier to orient yourself with the correct direction of a railway at a glance.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-station-direction-arrows.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Station detail with direction arrow indicators in relation to the center station&quot; title=&quot;Station detail with direction arrow indicators in relation to the center station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Station detail with direction arrow indicators in relation to the center station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The station detail screen now shows train frequencies during different periods of the day.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-station-frequencies.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Train frequencies by time period&quot; title=&quot;Train frequencies by time period&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Train frequencies by time period&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The bookmarks and search screens have been separated out into their own tabs. I doubled down on native iOS 26 UI and UX (for better or worse).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-tab-bar.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;iOS 26 liquid glass tab bar with native accessory for the route view&quot; title=&quot;iOS 26 liquid glass tab bar with native accessory for the route view&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iOS 26 liquid glass tab bar with native accessory for the route view&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In the search tab, you can now search railways.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-railway-search.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Railway search in the search tab&quot; title=&quot;Railway search in the search tab&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Railway search in the search tab&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Speaking of railways, the railway detail screen shows the full polyline of the railway.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-railway-detail.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Railway detail screen before and after with full polyline&quot; title=&quot;Railway detail screen before and after with full polyline&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Railway detail screen before and after with full polyline&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I think Eki Bright is the fastest and best way to get around Tokyo for everything but route planning. Local timetables give a serious edge to how fast you can find your next train.&lt;/p&gt;
</description>
        <pubDate>Sun, 18 Jan 2026 16:16:39 -0600</pubDate>
        <link>https://twocentstudios.com/2026/01/18/introducing-eki-bright-2.0/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2026/01/18/introducing-eki-bright-2.0/</guid>
        
        <category>app</category>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Closing the Loop on iOS with Claude Code</title>
        <description>&lt;p&gt;Closing the loop means giving Claude Code a way to view the output of its work. I’ll be focusing on iOS app development workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt; of closing the loop: &lt;strong&gt;building&lt;/strong&gt; a target so that Claude Code can see the errors and warnings. And doing so in a way that preserves the build cache (clean builds take a long time). This allows Claude Code to see its syntax errors and fix them before you review its work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2&lt;/strong&gt; of closing the loop: &lt;strong&gt;installing &amp;amp; launching&lt;/strong&gt; on the simulator. This saves you the step of opening Xcode and hitting build &amp;amp; run, letting you test each proposed code change right away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3&lt;/strong&gt; of closing the loop: reading the &lt;strong&gt;console &amp;amp; log output&lt;/strong&gt;. This allows Claude Code to proactively verify codepaths and reactively do debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4&lt;/strong&gt; of closing the loop: &lt;strong&gt;controlling &amp;amp; viewing&lt;/strong&gt; the iOS simulator. This allows Claude Code to step through entire flows, evaluate visual designs, and generate its own logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5&lt;/strong&gt; of closing the loop: building, installing, launching, and logging &lt;strong&gt;on device&lt;/strong&gt;. This allows you and Claude Code to test Apple Frameworks that are absent or broken on the simulator.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-hero.jpg&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Building, installing, launching on the simulator from Claude Code&quot; title=&quot;Building, installing, launching on the simulator from Claude Code&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Building, installing, launching on the simulator from Claude Code&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;disclaimers-before-we-start&quot;&gt;Disclaimers before we start&lt;/h3&gt;

&lt;p&gt;Agentic tooling is changing rapidly with model and agent versions. I’ll cover each step as thoroughly as I can. The strategies in this post cover about a month of work in &lt;strong&gt;December 2025&lt;/strong&gt; with Claude &lt;strong&gt;Opus 4.5&lt;/strong&gt; inside Claude Code v2.0.76 (and several versions below). I used &lt;strong&gt;Xcode 26.1 and 26.2&lt;/strong&gt; on macOS 15.7.3 mostly developing for &lt;strong&gt;iOS 26&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is written for humans but can easily be adapted to a Skill or added to your CLAUDE.md file. The command structure will change based on how your project and schemes are set up. I outline a few different strategies that are useful in different situations, but you may only want to use one workflow as your default, or completely ignore certain steps altogether.&lt;/p&gt;

&lt;p&gt;If you’ve always used a manual Xcode-based flow, trying to both understand and incorporate these steps into your workflow can be intimidating. If you’ve never working with Xcode via the command line before, start with just the first step for a while. The best part about this workflow is you can seamlessly dip in and out of using Xcode and there’s no switching cost (not even needing to do clean builds).&lt;/p&gt;

&lt;p&gt;The below CLI commands also share a lot of coverage with &lt;a href=&quot;https://github.com/cameroncooke/XcodeBuildMCP&quot;&gt;XcodeBuildMCP&lt;/a&gt;, a more full-service MCP-based solution. I won’t get into the pros and cons of MCPs vs CLIs (its author has already &lt;a href=&quot;https://www.async-let.com/posts/my-take-on-the-mcp-verses-cli-debate/&quot;&gt;written about that&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I’m specifically targeting this post to Claude Code and Opus 4.5 based on my first-hand knowledge of their combined capabilities. Other harness and model pairs will work with most of the commands in this post. The command backgrounding feature in step 3 is the only potential snag for some harnesses.&lt;/p&gt;

&lt;h2 id=&quot;step-1-building&quot;&gt;Step 1: Building&lt;/h2&gt;

&lt;p&gt;Allowing Claude Code to build after every proposed change is a requirement for agentic workflows. Like it does for human developers, the compiler catches dumb syntax errors and, with Swift concurrency, even data races. The alternative is tabbing back over to Xcode, hitting cmd+b, waiting, copying and pasting error messages into the terminal; a massive waste of human time.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;move-deriveddata-location-to-your-project-folder-optional&quot;&gt;Move DerivedData location to your project folder (optional)&lt;/h4&gt;

&lt;p&gt;Moving DerivedData to a location inside your project folder is perhaps an unusual suggestion, but it has several benefits for an agentic workflow:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Permissions&lt;/strong&gt;: you’ll encounter fewer permissions dialogs when Claude is reading inside the project folder that you’re presumably running it in. Most devs expect DerivedData to be cleared regularly so it’s safe.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Git worktrees&lt;/strong&gt;: An advanced technique is to use &lt;a href=&quot;https://git-scm.com/docs/git-worktree&quot;&gt;Git worktrees&lt;/a&gt; to have independent copies of your repo. Colocating DerivedData ensures the separate repos don’t interfere with each others build artifacts.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Docs&lt;/strong&gt;: The DerivedData has a full copy of your Swift Packages, including any documentation. Claude Code can do fast greps to verify syntax or find examples. In my CLAUDE.md I have a direct link for each important package:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-markdown highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`DerivedData/train-timetable/SourcePackages/checkouts/swift-composable-architecture/Sources/ComposableArchitecture/Documentation.docc`&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; When using Search/Grep/etc. tools, ignore anything in the /DerivedData folder by default unless specifically looking for build artifacts or code/docs for Swift Packages used by this project
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Before making this change, add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData/&lt;/code&gt; as a line in your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt; file if it’s not already there.&lt;/p&gt;

&lt;p&gt;Find the setting in Xcode Settings -&amp;gt; Locations. Set Derived Data to “Relative” and Build Location to “Unique”. It will report &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/DerivedData&lt;/code&gt; as the location.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-deriveddata-settings.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;DerivedData settings in Xcode&quot; title=&quot;DerivedData settings in Xcode&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;DerivedData settings in Xcode&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;document-project-file--scheme&quot;&gt;Document project file &amp;amp; scheme&lt;/h4&gt;

&lt;p&gt;Build commands use your project/workspace file location and scheme name. Claude Code can find these pretty easily with tools but it’s faster to document them in CLAUDE.md.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-scheme-selection.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Finding scheme names from Xcode&quot; title=&quot;Finding scheme names from Xcode&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Finding scheme names from Xcode&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;get-simulators&quot;&gt;Get simulators&lt;/h4&gt;

&lt;p&gt;I’ll assume you’ve already downloaded the iOS simulators and iOS runtime versions you’d like to use in the Xcode interface.&lt;/p&gt;

&lt;p&gt;The usual command you’ll use to find the simulator you want produces a very long list, so it’s reasonable to cache your favorite simulator’s UDID so each new Claude Code session doesn’t need do this from scratch each time. “Cache” meaning note it in your CLAUDE.md file, add it as an environment variable, etc.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get all available simulators
xcrun simctl list devices available

     -- iOS 26.1 --
         iPhone 17 Pro (89F6D0BC-E855-4BF7-A400-9C19ED7A7350) (Shutdown)
         iPhone 17 Pro Max (F1FA81FA-ED32-40C4-BD78-753254D685AC) (Shutdown)
         iPhone Air (77702E5F-85F5-4997-BA14-BC8D8F639B84) (Shutdown)
		 ...
     -- iOS 26.2 --
         iPhone 17 Pro (DB0531E0-B47E-42AC-9AAB-FEB76D3D563A) (Booted)
         iPhone 17 Pro Max (0C54CF4B-8A45-450E-AB93-B800B97BD4DA) (Shutdown)
         iPhone Air (83BECA5F-7894-4705-B198-3DCAE0C4778E) (Shutdown)
         ...
     -- ...
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;At first, you’ll probably start with a single threaded workflow, having one preferred simulator booted and in use at a time. The below command will output just the UDID for the latest iPhone Pro with the latest installed iOS version.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get the UDID of the latest iPhone Pro (non-max) model with the latest available iOS version 
xcrun simctl list devices available | grep &quot;iPhone.*Pro (&quot; | tail -1 | grep -Eo &apos;[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Once you have more Claudes running in parallel, you’ll can ask each to find its own UDID by looking for a non-booted simulator before it starts building.&lt;/p&gt;

&lt;p&gt;Note that even though some commands can be run with more vague identifiers like name, os, or “booted”, it’s much more reliable to select a UDID and use it across commands. For example: specifying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;platform=iOS Simulator,name=iPhone 17 Pro&quot;&lt;/code&gt; in the build command will pick any simulator that matches, which could be any iOS version. For the build command it’s not as big of an issue, but to ensure predictable runs I recommend using UDIDs only.&lt;/p&gt;

&lt;h4 id=&quot;install-xcsift&quot;&gt;Install xcsift&lt;/h4&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/ldomaradzki/xcsift&quot;&gt;xcsift&lt;/a&gt; is a companion parsing library for build output. You’ll be building &lt;em&gt;a lot&lt;/em&gt;, and you don’t want to fill up your context with hundreds of lines of “file.swift built”. xcsift solves this by producing just the actionable errors and warnings in json.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;brew install xcsift
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I add the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; flag to also include warnings in the output. I recommend browsing the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcsift&lt;/code&gt; docs to find other flags you might find useful for your project.&lt;/p&gt;

&lt;h3 id=&quot;building&quot;&gt;Building&lt;/h3&gt;

&lt;p&gt;OK, after all that setup, we should have all the info we need to assemble the actual build command.&lt;/p&gt;

&lt;p&gt;Claude Code will be able to derive and customize the exact build command you need. I recommend doing a quick session with Claude - the goal being to produce a single, always-working command you can document somewhere and use automatically in each future session.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example: working build command for a specific simulator
xcodebuild -project train-timetable.xcodeproj -scheme &quot;train-timetable&quot; -destination &quot;platform=iphonesimulator,id=DB0531E0-B47E-42AC-9AAB-FEB76D3D563A&quot; -derivedDataPath DerivedData -configuration Debug build 2&amp;gt;&amp;amp;1 | xcsift -w
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-project&lt;/code&gt;&lt;/strong&gt;: path to your xcodeproj file. Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-workspace&lt;/code&gt; if you use an xcworkspace file.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-scheme&lt;/code&gt;&lt;/strong&gt;: scheme name we found above.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-destination&lt;/code&gt;&lt;/strong&gt;: for simulator, we use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;platform=iphonesimulator,id=$UDID&quot;&lt;/code&gt; where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; is the UDID of our favorite simulator instance. Note &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iOS Simulator&lt;/code&gt; has the same meaning and also works.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-derivedDataPath&lt;/code&gt;&lt;/strong&gt;: this is super important if you’ve moved the DerivedData to the project folder. Without this, the Xcode instance will be using a different directory and you’ll have super slow (clean) builds each time.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-configuration&lt;/code&gt;&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug&lt;/code&gt; is the default, so you don’t usually need this flag. It’s better to be explicit though because this affects the folder where your app binary will be copied to (see step 2).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;build&lt;/strong&gt;: the actual build command&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2&amp;gt;&amp;amp;1 | xcsift -w&lt;/code&gt;&lt;/strong&gt;: combines stdout and stderr and pipes them both into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcsift&lt;/code&gt; so it has access to all output. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; tells &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcsift&lt;/code&gt; to also show build warnings, not just errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s important to test your ideal build command to confirm:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It doesn’t force a clean build each time.&lt;/li&gt;
  &lt;li&gt;It produces a concise set of errors and warnings.&lt;/li&gt;
  &lt;li&gt;It doesn’t interfere with builds via Xcode; you should be able to build/install/run from Xcode, use other SourceKit features, etc. and not clear the build cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my understanding for builds, the simulator UDID (or at least simulator name) does not affect the build artifacts or app binary. However, there’s a lot going on behind the scenes so for simplicity I recommend using the same simulator UDID across all build, install, &amp;amp; launch steps. You need to be careful if running multiple simulators from the same project folder (i.e. without git worktrees) because each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build&lt;/code&gt; command will overwrite the app binary regardless of whether you run the build command with different simulator UDIDs.&lt;/p&gt;

&lt;h3 id=&quot;clearing-deriveddata&quot;&gt;Clearing DerivedData&lt;/h3&gt;

&lt;p&gt;When left to its own devices (literally), sometimes Claude will get frustrated when a build is failing continuously and it can’t figure out how to fix things. It will sometimes try to remove the entire DerivedData folder. This is a bad idea because 1. clearing DerivedData usually doesn’t fix the underlying problem and 2. it will temporarily break Xcode’s ability to read your Swift packages and you’ll need to restart Xcode to get everything working again.&lt;/p&gt;

&lt;p&gt;After I got my build commands more streamlined, Claude stopped doing this as much. But I still have decently strict permissions, so when it does happen, the session will usually block on any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rm&lt;/code&gt; command and I’ll get a chance to step in and reprimand it. If you run into this problem, you can dig deeper into a configuration-based solution and modify your permissions, add hooks, or add more to your CLAUDE.md. Just something to look out for.&lt;/p&gt;

&lt;h2 id=&quot;step-2-installing--launching&quot;&gt;Step 2: Installing &amp;amp; Launching&lt;/h2&gt;

&lt;p&gt;Building should streamline a lot of your Claude Code workflow. But I slept on the automated install &amp;amp; launch step for too long.&lt;/p&gt;

&lt;p&gt;When properly set up with step 1, you should be able to wait for Claude Code to build its changes and return control to you. Then you can tab over to Xcode and hit “run without building” to handle the install &amp;amp; launch.&lt;/p&gt;

&lt;p&gt;But when you’re doing build-&amp;gt;install-&amp;gt;launch dozens of times a day, it’s way more streamlined to check Claude’s session output then tab over to the simulator and tap through screens to test out the changes.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites-1&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;document-the-app-binary-location-for-your-scheme&quot;&gt;Document the app binary location for your scheme&lt;/h4&gt;

&lt;p&gt;Ask Claude to find the location of the app binary produced by the build command.&lt;/p&gt;

&lt;p&gt;Since my setup has DerivedData in the project folder and a build directory inside it, that’s where my app binary is: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData/Build/Products/Debug-iphonesimulator/Eki Bright.app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You’ll notice the folder: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug-iphonesimulator&lt;/code&gt;, which corresponds to our build configuration of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug&lt;/code&gt; from earlier and the platform &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iphonesimulator&lt;/code&gt;. If you go off the beaten path and want to try out a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Release&lt;/code&gt; build for example, make sure to understand this relationship.&lt;/p&gt;

&lt;p&gt;Also be cautious because you want to make sure the most recent build is what you’re installing and launching and looking at. And there’s nothing in the file name that will indicate that.&lt;/p&gt;

&lt;h4 id=&quot;find-your-bundle-identifier&quot;&gt;Find your bundle identifier&lt;/h4&gt;

&lt;p&gt;This will be in your Target’s general settings pane beside Bundle Identifier: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.twocentstudios.train-timetable&lt;/code&gt;&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-bundle-id.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Bundle identifier in Xcode target settings&quot; title=&quot;Bundle identifier in Xcode target settings&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Bundle identifier in Xcode target settings&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;installing-the-app-on-the-simulator&quot;&gt;Installing the app on the simulator&lt;/h3&gt;

&lt;p&gt;Installing is copying over the app binary into a specific simulator’s storage. &lt;strong&gt;This step depends on the build step having produced an app binary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The parameters in the install command are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: for least headaches this should be the same simulator UDID you specified in the build command.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;path/to/My App.app&lt;/strong&gt;: the app binary location specified by your scheme.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example: install a previously built app binary on the simulator by UDID
xcrun simctl install DB0531E0-B47E-42AC-9AAB-FEB76D3D563A &quot;DerivedData/Build/Products/Debug-iphonesimulator/Eki Bright.app&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;launching-the-app-on-the-simulator&quot;&gt;Launching the app on the simulator&lt;/h3&gt;

&lt;p&gt;Launching the equivalent of tapping your app’s icon in Springboard. It of course depends on the install step having copied over the app binary.&lt;/p&gt;

&lt;p&gt;The parameters in the install command are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bundle id&lt;/strong&gt;: apps are uniquely identified by bundle id after installation.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example: launch a previously installed app binary by bundle id
xcrun simctl launch DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;navigating-by-url-bonus&quot;&gt;Navigating by URL (bonus)&lt;/h3&gt;

&lt;p&gt;Depending on your app’s navigation structure and pre-existing support for universal links or App Intents, you can save yourself even more time by having Claude automatically navigate the app to the tab, sheet, or navigation destination you’re currently testing.&lt;/p&gt;

&lt;p&gt;The full set of caveats is beyond the scope of this post. In my experience, adding Universal Links support without some caution can lead to giving Claude access to data or flows that are impossible for normal app users to see. It may also add maintenance burden for initializers that are only used during debug. Regardless, jumping through a dozen screens automatically can save you hours of unnecessary manual screen-clicking labor.&lt;/p&gt;

&lt;p&gt;The parameters for the openurl command are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;URL&lt;/strong&gt;: the deep link URL your app knows how to process.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl openurl DB0531E0-B47E-42AC-9AAB-FEB76D3D563A &quot;train-timetable://tab?name=search&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In my testing, it’s safe to have Claude to run the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openurl&lt;/code&gt; command &lt;em&gt;immediately&lt;/em&gt; after the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;install&lt;/code&gt; command (or even in the same line) without needing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep&lt;/code&gt; or otherwise waiting for the launch to complete.&lt;/p&gt;

&lt;h2 id=&quot;step-3-reading-console--log-output&quot;&gt;Step 3: Reading Console &amp;amp; Log Output&lt;/h2&gt;

&lt;p&gt;With Step 1, Claude has access to the compiler’s evaluation of its code changes. We can also give Claude access to the console and log outputs so it can evaluate the runtime results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are two strategies&lt;/strong&gt;: console output via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; statements and log output via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OSLog&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Logger&lt;/code&gt;. I use both depending on the situation.&lt;/p&gt;

&lt;p&gt;Depending on the strategy, we’ll either amend the launch command from step 2 or prepend a CLI command.&lt;/p&gt;

&lt;h3 id=&quot;blocking-vs-non-blocking&quot;&gt;Blocking vs. non-blocking&lt;/h3&gt;

&lt;p&gt;Claude can do &lt;em&gt;blocking&lt;/em&gt; and &lt;em&gt;non-blocking&lt;/em&gt; for the console variant, and &lt;em&gt;non-blocking&lt;/em&gt;-only for the log output.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Blocking&lt;/em&gt; means that the prompt input and Claude’s thinking will be suspended until you explicitly stop it or the default timeout (currently 10 minutes) triggers. It will print output from the command inline, but usually truncate portions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Non-blocking&lt;/em&gt; means Claude will use its background capability to keep the command alive and retain access to its output but immediately move the command to the background so that the prompt input is available.&lt;/p&gt;

&lt;p&gt;I recommend the &lt;em&gt;blocking&lt;/em&gt; flow for when you want to add a few quick print statements to verify a limited (maybe less than 15 seconds) code execution flow that you, the human, are driving in the simulator and have Claude immediately evaluate the results inline. The amount of lines generated should be small, within 10s of lines.&lt;/p&gt;

&lt;p&gt;I recommend &lt;em&gt;non-blocking&lt;/em&gt; for all other scenarios, including:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;when you want Claude to drive the simulator (discussed in step 4) while monitoring the output.&lt;/li&gt;
  &lt;li&gt;when you want to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Logger&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; logging, probably for more permanent logging code in your codebase.&lt;/li&gt;
  &lt;li&gt;when you’re expecting to generate dozens or hundreds of lines of logs in a single run. In order to be smart about preserving the session context, you’ll want to write to a file and allow either a subagent to extract meaning from it, or have the primary agent use parsing tools to read only the relevant portions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;--terminate-running-process&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Adding the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt; flag to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; ensures idempotency by terminating any existing instance of your app and ensuring the app is always cold launched with the console output available.&lt;/p&gt;

&lt;p&gt;Adding the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt; is super important to the logging flow since you may not be rebuilding and reinstalling between launches.&lt;/p&gt;

&lt;p&gt;When you don’t terminate an existing process, the app instance will stay in memory on the simulator. By default, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command will &lt;strong&gt;not&lt;/strong&gt; relaunch the app if it’s already launched. This will happen silently. Critically, it will also &lt;strong&gt;not&lt;/strong&gt; read any console output and Claude will get very confused about why nothing is being logged and it will start thrashing and making very dumb changes, ranging from adding more print commands to clearing DerivedData.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(This was my biggest roadblock in getting a reliable and robust debugging flow with Claude; please learn from my mistakes).&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;launching-the-app-on-simulator-and-reading-the-output&quot;&gt;Launching the app on simulator and reading the output&lt;/h3&gt;

&lt;p&gt;Replace the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; commands from step 2 with any of the below variants depending on your use case.&lt;/p&gt;

&lt;h4 id=&quot;blocking-consoleprint-direct-when-you-know-output-volume-is-reasonable&quot;&gt;Blocking console/print direct (when you know output volume is reasonable)&lt;/h4&gt;

&lt;p&gt;Relevant flags and parameters:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console-pty&lt;/code&gt;&lt;/strong&gt;: produce console print output.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;&lt;/strong&gt;: as discussed above, ensure the command actually runs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt; - the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bundle id&lt;/strong&gt; - bundle id of the target that produces the app binary.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Example with simulator UDID and bundle id
xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note: the flags &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--stdout --stderr&lt;/code&gt; do not work. Don’t use them. Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console-pty&lt;/code&gt; instead.&lt;/p&gt;

&lt;h4 id=&quot;blocking-consoleprint-to-file-safer-for-unknown-or-expected-heavy-output&quot;&gt;Blocking console/print to file (safer for unknown or expected heavy output)&lt;/h4&gt;

&lt;p&gt;Relevant flags and parameters:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console-pty&lt;/code&gt;&lt;/strong&gt;: produce console print output.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;&lt;/strong&gt;: as discussed above, ensure the command actually runs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt;: the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bundle id&lt;/strong&gt;: bundle id of the target that produces the app binary.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;output file path&lt;/strong&gt;: the plain text file console output will be written to. Note: I write to a tmp folder within DerivedData to ensure Claude has access to the result without triggering unnecessary permissions dialogs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2&amp;gt;&amp;amp;1&lt;/code&gt;&lt;/strong&gt;: ensure stdout &amp;amp; stderr both end up in the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable &amp;gt; DerivedData/tmp/console.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;non-blocking-consoleprint&quot;&gt;Non-blocking console/print&lt;/h4&gt;

&lt;p&gt;Non-blocking requires using Claude Code’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run_in_background&lt;/code&gt; parameter on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bash&lt;/code&gt; tool. This will produce a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task_id&lt;/code&gt; that Claude can later use to get the output (from an implicitly created text file) and kill the task.&lt;/p&gt;

&lt;p&gt;After running the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bash&lt;/code&gt; tool, the prompt will be unblocked and you can ask Claude to monitor the output or ask it do anything else you want.&lt;/p&gt;

&lt;p&gt;The non-blocking flow requires a bit more ceremony; you’ll need to tell Claude when you’re done working with the simulator and it should analyze the results. It usually leaves the background task running (potentially writing log data to the output), so you’ll need to specifically tell it to stop.&lt;/p&gt;

&lt;p&gt;The command itself is the same as the one from &lt;em&gt;Blocking console/print direct&lt;/em&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bash(
  command: &quot;xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable&quot;,
  run_in_background: true
)

Command running in background with ID: b8e2ca5.

# *wait for next user prompt*

TaskOutput(task_id: &quot;b8e2ca5&quot;)
KillShell(shell_id: &quot;b8e2ca5&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;non-blocking-loggeroslog&quot;&gt;Non-blocking Logger/OSLog&lt;/h4&gt;

&lt;p&gt;With only its training data, Claude knows how to use &lt;a href=&quot;https://developer.apple.com/documentation/os/logging&quot;&gt;Logging&lt;/a&gt; by importing the OSLog framework. OSLog has strengths and weaknesses compared to console/print logging. You may already be using it in your app. I consider it more of a long term solution you’d add to your codebase alongside each feature and keep it up to date with any changes.&lt;/p&gt;

&lt;p&gt;Giving Claude access to these logs is different from the print/console flow we just discussed.&lt;/p&gt;

&lt;p&gt;The root command is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcrun simctl spawn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; only captures logs emitted while it’s running (not before). If you want logs starting from launch, always run it before the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;Blocking on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; doesn’t make sense because you still need to launch the app. You should dispatch it directly to the background as non-blocking.&lt;/p&gt;

&lt;p&gt;Relevant flags and parameters (Claude knows how to adjust these freely):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;UDID&lt;/strong&gt; - the simulator UDID you specified in the build &amp;amp; install commands.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--level&lt;/code&gt;&lt;/strong&gt;: matches the log level in your code; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;info&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;warning&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error&lt;/code&gt;, etc.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--predicate&lt;/code&gt;&lt;/strong&gt;: filters the firehose output the messages you’re interested in. Lots of options here depending on how you’ve defined &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Logger&lt;/code&gt;s and added log statements in your codebase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; is dispatched to the background, you’ll need to launch the app with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command. You can choose a blocking or non-blocking &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;Note that the raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn log stream&lt;/code&gt; command is not actually monitoring the specific app process. You can start this early in your session, cast a wide net, and keep this running through your whole session, asking Claude to filter the relevant time periods from the output. I personally haven’t needed this flow though.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bash(
  command: &quot;xcrun simctl spawn DB0531E0-B47E-42AC-9AAB-FEB76D3D563A log stream --level=debug --predicate &apos;subsystem == &quot;com.twocentstudios.train-timetable&quot;&apos;)&quot;
  run_in_background: true
)

Command running in background with ID: b8e2ca5.

Bash(xcrun simctl launch --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable)

# *blocking prompt until user escapes*

TaskOutput(task_id: &quot;b8e2ca5&quot;)
KillShell(shell_id: &quot;b8e2ca5&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;non-blocking-consoleprint--loggeroslog&quot;&gt;Non-blocking console/print &amp;amp; Logger/OSLog&lt;/h4&gt;

&lt;p&gt;You can combine everything above and give Claude access to both console/print output and Logger/OSLog output. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launch&lt;/code&gt; command can be blocking or non-blocking, but the below example is non-blocking.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Bash(
  command: &quot;xcrun simctl spawn DB0531E0-B47E-42AC-9AAB-FEB76D3D563A log stream --level=debug --predicate &apos;subsystem == &quot;com.twocentstudios.train-timetable&quot;&apos;)&quot;
  run_in_background: true
) 
Command running in background with ID: b8e2ca5.

Bash(
  command: &quot;xcrun simctl launch --console-pty --terminate-running-process DB0531E0-B47E-42AC-9AAB-FEB76D3D563A com.twocentstudios.train-timetable&quot;,
  run_in_background: true
)
Command running in background with ID: a792db1.

# *wait for next user prompt*
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h2 id=&quot;step-4-controlling--viewing-the-ios-simulator&quot;&gt;Step 4: Controlling &amp;amp; Viewing the iOS simulator&lt;/h2&gt;

&lt;p&gt;Giving Claude eyes and virtual fingers to see and control the iOS simulator is where we start to reach the avant-garde. At the current (end of 2025) model &amp;amp; harness capabilities things start to go off the rails pretty quickly. I wouldn’t expect great results from Claude at tasks related to manipulating the simulator like a human, but in certain scenarios, the benefits outweigh the costs.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites-2&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;axe&quot;&gt;AXe&lt;/h4&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;a href=&quot;https://github.com/cameroncooke/AXe&quot;&gt;AXe&lt;/a&gt; is a comprehensive CLI tool for interacting with iOS Simulators using Apple’s Accessibility APIs and HID (Human Interface Device) functionality.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude can use AXe to manipulate the simulator through taps, swipes, button presses, and keyboard typing.&lt;/p&gt;

&lt;p&gt;Under the hood, AXe uses Facebook’s &lt;a href=&quot;https://github.com/facebook/idb&quot;&gt;idb&lt;/a&gt; CLI.&lt;/p&gt;

&lt;p&gt;Install AXe with Homebrew.&lt;/p&gt;

&lt;h4 id=&quot;image-magick-optional&quot;&gt;Image Magick (optional)&lt;/h4&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;a href=&quot;https://github.com/ImageMagick/ImageMagick&quot;&gt;ImageMagick&lt;/a&gt;® is a free and open-source software suite, used for editing and manipulating digital images.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude can use ImageMagick to do some post-processing on screenshots from the simulator.&lt;/p&gt;

&lt;p&gt;Install ImageMagick with Homebrew.&lt;/p&gt;

&lt;h4 id=&quot;ffmpeg-optional&quot;&gt;FFmpeg (optional)&lt;/h4&gt;

&lt;p&gt;Claude can use the venerable &lt;a href=&quot;https://www.ffmpeg.org/&quot;&gt;FFmpeg&lt;/a&gt; CLI for advanced video manipulation use cases. You may not need it but there’s a good chance you already have it.&lt;/p&gt;

&lt;h3 id=&quot;reading-from-the-simulator&quot;&gt;Reading from the simulator&lt;/h3&gt;

&lt;p&gt;In order to navigate the simulator beyond the universal links &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;openurl&lt;/code&gt; use case we detailed above, Claude needs to be able to see the current state of the simulator.&lt;/p&gt;

&lt;p&gt;There are 3 options for this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Accessibility info&lt;/strong&gt; - Claude can read a hierarchical text description of the current screen using accessibility info.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Screenshots&lt;/strong&gt; - Claude can take a screenshot of the simulator and use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Read&lt;/code&gt; tool to access its multimodal capabilities.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Video&lt;/strong&gt; - Claude can record a short video capture of the simulator, slice it up into frames, and read a few to assess an animation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;accessibility-info-via-describe-ui&quot;&gt;Accessibility info via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;describe-ui&lt;/code&gt;&lt;/h4&gt;

&lt;p&gt;The AXe command for getting the accessibility trace is:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;axe describe-ui --udid SIMULATOR_UDID

  ...
      {
        &quot;frame&quot;: {&quot;y&quot;: 82, &quot;x&quot;: 346, &quot;width&quot;: 36, &quot;height&quot;: 36},
        &quot;AXLabel&quot;: &quot;閉じる&quot;,
        &quot;type&quot;: &quot;Button&quot;
      }
  ...
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The output is a big JSON array.&lt;/p&gt;

&lt;p&gt;I thought Claude would be better at understanding and navigation with text information than image information, but in practice it almost always ignored my instructions in CLAUDE.md to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;describe-ui&lt;/code&gt; before the screenshot flow. Perhaps there’s something in the system prompt or it’s less efficient to hunt through all the text.&lt;/p&gt;

&lt;p&gt;I also immediately ran into a &lt;a href=&quot;https://github.com/cameroncooke/AXe/issues/8&quot;&gt;reported issue&lt;/a&gt; in AXe and idb where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;describe-ui&lt;/code&gt; does not print tab or toolbar info, perhaps only from iOS 26. This makes it very difficult to deterministically do any sort of navigation from the root in many apps.&lt;/p&gt;

&lt;p&gt;All this is to say that, at the moment, it’s more reliable to use screenshots.&lt;/p&gt;

&lt;h4 id=&quot;screenshots&quot;&gt;Screenshots&lt;/h4&gt;

&lt;p&gt;Claude can use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simctl&lt;/code&gt; to get screenshots.&lt;/p&gt;

&lt;p&gt;Like the other commands, I prefer to write to a tmp folder within DerivedData.&lt;/p&gt;

&lt;p&gt;Screenshots for most simulators are taken at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3x&lt;/code&gt; scale, but input taps and swipes are at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1x&lt;/code&gt;. For the dual purposes of 1. reducing the amount of calculation required to translate screen position to next tap position and 2. reducing the amount of image data that needs to be sent to and processed by Claude, I automatically resize all screenshots to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1x&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;magick&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl io DB0531E0-B47E-42AC-9AAB-FEB76D3D563A screenshot DerivedData/tmp/screen.png &amp;amp;&amp;amp; magick DerivedData/tmp/screen.png -resize 33.333% DerivedData/tmp/screen_1x.png
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h4 id=&quot;video&quot;&gt;Video&lt;/h4&gt;

&lt;p&gt;Reading live or even recorded video is currently beyond Opus 4.5’s capabilities. I’m guessing this will be a supported flow sometime in 2026, but until then analyzing video output of the simulator is still at proof-of-concept maturity.&lt;/p&gt;

&lt;p&gt;While I was debugging a tricky animation, I gave Claude some leash to test whether it could:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;start recording a short clip immediately before a tap.&lt;/li&gt;
  &lt;li&gt;stop the recording after 2 seconds.&lt;/li&gt;
  &lt;li&gt;use FFmpeg to grab 5 or 6 frames spaced out across the video.&lt;/li&gt;
  &lt;li&gt;read the frames and analyze the motion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It sort of worked? Not really? If you have a use case, you can try experimenting more with this flow. For now I’d consider the actual animation analysis a human-only endeavor. But Claude can still help get the simulator staged up to the start screen.&lt;/p&gt;

&lt;p&gt;I believe I used this AXe command as a Claude Code background task:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;axe record-video --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --fps 30 --output DerivedData/tmp/recording.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;manipulating-the-simulator-with-taps-and-swipes&quot;&gt;Manipulating the simulator with taps and swipes&lt;/h3&gt;

&lt;p&gt;AXe has a variety of tap and gesture commands. Claude can tap on points or accessibility labels.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Tap at logical coordinates (use frame center from describe-ui)&lt;/span&gt;
axe tap -x 201 -y 297 --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Without additional guidance, &lt;strong&gt;Claude gets confused about which scroll command maps to what logical direction&lt;/strong&gt;.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Scroll (named by finger direction, not content direction)&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# scroll-up = finger UP = content UP = see content BELOW = triggers .onScrollDown&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# scroll-down = finger DOWN = content DOWN = see content ABOVE&lt;/span&gt;
axe gesture scroll-down --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
axe gesture scroll-up --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;It’s useful to note the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swipe-from-left-edge&lt;/code&gt; gesture because it’s the quickest way for Claude to pop back a level in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NavigationStack&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Edge swipes (for back navigation, etc.)&lt;/span&gt;
axe gesture swipe-from-left-edge --udid DB0531E0-B47E-42AC-9AAB-FEB76D3D563A --post-delay 0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;strategies-for-increasing-tap-accuracy&quot;&gt;Strategies for increasing tap accuracy&lt;/h3&gt;

&lt;p&gt;The most significant source of indeterministic behavior is in Claude’s ability to accurately measure of coordinates on screen. In other words, it can’t read an image and always find the center point of a button. This means there is plenty of opportunity for situations like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Claude reads a screen and wants to tap a button.&lt;/li&gt;
  &lt;li&gt;Claude makes a bad guess and taps above the button.&lt;/li&gt;
  &lt;li&gt;Claude reads the screen again. There was no change.&lt;/li&gt;
  &lt;li&gt;Claude makes another bad guess and taps below the button.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude’s only feedback about whether its tap was successful is based on its next screenshot. This can lead to situations where it gets irrecoverably lost while navigating your app:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Claude reads a screen and wants to tap a button.&lt;/li&gt;
  &lt;li&gt;Claude makes a bad guess and taps above the button, hitting a completely different button.&lt;/li&gt;
  &lt;li&gt;Claude reads the screen again.&lt;/li&gt;
  &lt;li&gt;Claude sees it’s on a different screen than expected and becomes confused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I came up with an experimental flow to try to improve Claude’s accuracy, but:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It slows down the entire process by 2x.&lt;/li&gt;
  &lt;li&gt;By the time Claude realizes it needs to use the experimental flow, it’s already too far lost to recover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Regardless, my flow, also using ImageMagick, is to make Claude draw a red circle on a screenshot in its targeted tap location before actually performing the tap:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Take screenshot and resize to 1x (so pixels = points):
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun simctl io DB0531E0-B47E-42AC-9AAB-FEB76D3D563A screenshot DerivedData/tmp/screen.png &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; magick DerivedData/tmp/screen.png -resize 33.333% DerivedData/tmp/screen_1x.png
&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;Read the 1x image and estimate target element center in points&lt;/li&gt;
  &lt;li&gt;Verify guess by drawing a red box at those coordinates:
    &lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;magick DerivedData/tmp/screen_1x.png -fill none -stroke red -strokewidth 2 -draw &lt;span class=&quot;s2&quot;&gt;&quot;rectangle &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;X-30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;Y-30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;X+30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;$((&lt;/span&gt;Y+30&lt;span class=&quot;k&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; DerivedData/tmp/screen_marked.png
&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;Read marked image to check if box is on target&lt;/li&gt;
  &lt;li&gt;If missed, adjust coordinates and repeat from step 3&lt;/li&gt;
  &lt;li&gt;If correct, tap at the verified coordinates&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/closing-loop-cc-tap-accuracy.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Tap accuracy verification flow - original screenshot, marked target, and result after tap&quot; title=&quot;Tap accuracy verification flow - original screenshot, marked target, and result after tap&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Tap accuracy verification flow - original screenshot, marked target, and result after tap&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;putting-it-all-together&quot;&gt;Putting it all together&lt;/h3&gt;

&lt;p&gt;So far in practice, I’ve used this capability alongside universal links to fix and verify simple visual bugs. I have Claude craft input to reproduce the error and find the screen where it occurs, take a “before” screenshot, implement the fix, build/install/launch, then find the same screen, verify the fix, and take an “after” screenshot.&lt;/p&gt;

&lt;p&gt;It takes way longer for Claude to do than me, but it’s mostly tedious work, and I’m usually in another tab working on a plan with another Claude. When I come back and see the before and after screenshots alongside the code change, I can feel confident in Claude’s work.&lt;/p&gt;

&lt;h2 id=&quot;step-5-building-installing-launching-reading-output-on-a-physical-device&quot;&gt;Step 5: Building, Installing, Launching, Reading Output on a Physical Device&lt;/h2&gt;

&lt;p&gt;Finally, for those Apple SDKs that only work on device, for ensuring observed buggy behavior isn’t just a simulator quirk, or just to get a more realistic look at our apps in context, we can implement steps 1, 2, and 3 on a physical device. Unfortunately, as far as I can tell, there’s no way to control a physical device via CLI tool, so step 4 is out reach for now.&lt;/p&gt;

&lt;p&gt;However, building, installing, launching, and logging can still save some time and annoyance during iterative debugging sessions. It’s especially useful to have Claude help analyze logs for (underdocumented) frameworks like Core Location that behave wildly different on a real device than on the simulator.&lt;/p&gt;

&lt;p&gt;Below is a collection of tested CLI commands for doing all the above tasks on a physical device.&lt;/p&gt;

&lt;p&gt;Note that in my testing all the relevant commands below work equally for devices on the same network and devices connected directly to your Mac via USB.&lt;/p&gt;

&lt;h3 id=&quot;prerequisites-3&quot;&gt;Prerequisites&lt;/h3&gt;

&lt;h4 id=&quot;get-devices&quot;&gt;Get devices&lt;/h4&gt;

&lt;p&gt;The device &lt;strong&gt;Name&lt;/strong&gt; and &lt;strong&gt;Identifier&lt;/strong&gt; are both important for on-device debugging. &lt;strong&gt;State&lt;/strong&gt; will be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;available&lt;/code&gt; when Wi-Fi debugging is available, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;connected&lt;/code&gt; when directly connect via USB.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Get all connected devices&lt;/span&gt;
xcrun devicectl list devices

Name          Hostname                      Identifier                             State                Model                                
-----------   ---------------------------   ------------------------------------   ------------------   -------------------------------------
CT&lt;span class=&quot;s1&quot;&gt;&apos;s iPhone   CTs-iPhone.coredevice.local   ABCDEF01-1111-5555-AAAA-F7D81A900001   connected (no DDI)   iPhone 14 Pro (iPhone15,2)           
CT’s iPad     CTs-iPad.coredevice.local     ABCDEF01-2222-6666-BBBB-F44A19F00002   available            iPad Pro (11-inch) (iPad8,1)         
CT’s iPad     CTs-iPad-1.coredevice.local   ABCDEF01-3333-7777-CCCC-D33889000003   unavailable          iPad mini (5th generation) (iPad11,1)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Some example use cases for parsing out values in one go:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get the Identifier of the first accessible iPhone (WiFi or USB)
xcrun devicectl list devices | grep &quot;iPhone&quot; | grep -E &quot;(available|connected)&quot; | head -1 | grep -Eo &apos;[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}&apos;

ABCDEF01-1111-5555-AAAA-F7D81A900001
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Get the name of the first accessible iPhone (WiFi or USB)
xcrun devicectl list devices | grep &quot;iPhone&quot; | grep -E &quot;(available|connected)&quot; | head -1 | awk -F&apos;  +&apos; &apos;{print $1}&apos;

CT&apos;s iPhone
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;build-for-device&quot;&gt;Build for device&lt;/h3&gt;

&lt;p&gt;Build commands are the same as those for the simulator, except &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iOS&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iphonesimulator&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iOS Simulator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; of your target device from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list devices&lt;/code&gt; command.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Using device name
xcodebuild -project train-timetable.xcodeproj -scheme &quot;train-timetable&quot; -destination &quot;platform=iOS,name=CT&apos;s iPhone&quot; -derivedDataPath DerivedData build 2&amp;gt;&amp;amp;1 | xcsift -w
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform=iphoneos&lt;/code&gt; does not work. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; does not work.&lt;/p&gt;

&lt;h3 id=&quot;install-on-device&quot;&gt;Install on device&lt;/h3&gt;

&lt;p&gt;Install commands use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;devicectl&lt;/code&gt; but are the similar to those for the simulator, except &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--device&lt;/code&gt; should use the device ID, and the build product directory should use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug-iphoneos&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Debug-iphonesimulator&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun devicectl device install app --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB &quot;DerivedData/Build/Products/Debug-iphoneos/Eki Bright.app&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;launch-on-device&quot;&gt;Launch on device&lt;/h3&gt;

&lt;p&gt;The vanilla launch command is below. I again recommend using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-existing&lt;/code&gt;, the device equivalent of the simulator’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--terminate-running-process&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun devicectl device process launch --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB --console --terminate-existing com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;blocking-consoleprint-capture-on-device&quot;&gt;Blocking console/print capture on device&lt;/h3&gt;

&lt;p&gt;For console/print capture, use the launch command above with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--console&lt;/code&gt; flag. It works over USB and Wi-Fi.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;xcrun devicectl device process launch --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB --console --terminate-existing com.twocentstudios.train-timetable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;non-blocking-consoleprint-capture-on-device&quot;&gt;Non-blocking console/print capture on device&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Use run_in_background: true on Bash tool
# Works over USB and WiFi
Bash(
  command: &quot;xcrun devicectl device process launch --device E7E3E660-9E7A-5814-8BBB-F7D81A965CEB --console --terminate-existing com.twocentstudios.train-timetable&quot;,
  run_in_background: true
)

Command running in background with ID: b8e2ca5.

# *wait for next user prompt*

TaskOutput(task_id: &quot;b8e2ca5&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;loggeroslog-capture-on-device-requires-manual-sudo&quot;&gt;Logger/OSLog capture on device (requires manual sudo)&lt;/h3&gt;

&lt;p&gt;A downside of OSLog is that it requires &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo&lt;/code&gt; and Claude Code can’t use sudo commands directly. There are presumably some ways to give Claude this capability in more a dangerous fashion. But a safer workaround for now is for you, the human, to run the below commands in another terminal tab. Claude can give you the full command to copy/paste into the other terminal.&lt;/p&gt;

&lt;p&gt;Another downside is that the process is slow and produces lots of logs.&lt;/p&gt;

&lt;p&gt;These commands will produce groups of files that Claude can read.&lt;/p&gt;

&lt;p&gt;Note that the log collect is of &lt;strong&gt;everything on the device&lt;/strong&gt; in the past, and can quickly balloon to gigabytes of storage. The command to collect even the last 2 minutes of logs can take about 30 seconds to complete on Wi-Fi.&lt;/p&gt;

&lt;p&gt;Content filtering with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--predicate&lt;/code&gt; is not supported: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Warning: --predicate is ignored when collecting from attached device&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Claude will handle filtering while reading/analyzing with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log show --predicate ...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most logical way to use this is to:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;have Claude build, install, and launch the app on your device (ensure it’s unlocked), and have it note the start time.&lt;/li&gt;
  &lt;li&gt;tap around and do the testing you need in order to generate the logs you want.&lt;/li&gt;
  &lt;li&gt;have Claude give you the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo log collect&lt;/code&gt; command with a start time a little before the launch time.&lt;/li&gt;
  &lt;li&gt;run the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo log collect&lt;/code&gt; command in a separate terminal window, enter your password, wait ~1m for it to finish.&lt;/li&gt;
  &lt;li&gt;ask Claude to analyze the log archive.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Human user must run these commands in another terminal tab (Claude Code can&apos;t provide sudo password)

# `--last` collects from N minutes before the command was run
sudo log collect --device-name &quot;CT&apos;s iPhone&quot; --last 2m --output DerivedData/tmp/device-logs.logarchive

# `--start` collects from the specified start time until the command was run
sudo log collect --device-name &quot;CT&apos;s iPhone&quot; --start &quot;2025-12-30 16:11:00&quot; --output DerivedData/tmp/device-logs.logarchive
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Then analyze with log show (Claude can do this)
log show DerivedData/tmp/device-logs.logarchive --predicate &apos;subsystem == &quot;com.twocentstudios.train-timetable&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--device-udid&lt;/code&gt; does not work, use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--device-name instead&lt;/code&gt; - &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log: failed to create archive: Device not configured (6)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Note &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--size&lt;/code&gt; does not (seem to) work either.&lt;/p&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;

&lt;h3 id=&quot;how-to-parameterize-names-ids-etc-for-these-commands&quot;&gt;How to parameterize names, ids, etc. for these commands&lt;/h3&gt;

&lt;p&gt;So far, I’ve just been hardcoding these commands with my favorite simulator UDID and project path into my CLAUDE.md. When a new version of Xcode comes out I ask Claude to update all mentions of the UDID to the most recent simulator version and it only takes a minute. Hardcoding these values leaves the least room for hallucination. When running these commands dozens of times a day, you really want consistency.&lt;/p&gt;

&lt;p&gt;Other ways to handle this would be:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;set environment variables at some level.&lt;/li&gt;
  &lt;li&gt;add a start hook to have Claude fill in the environment variables fresh for each session.&lt;/li&gt;
  &lt;li&gt;set up another layer of orchestration that handles the pool of simulators and dispatches an ID to each new Claude instance that requests one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all beyond the scope of this post. If you work primarily on one project with others, you probably already have some tooling for specifying the Xcode version, etc.&lt;/p&gt;

&lt;h3 id=&quot;why-not-include-traditional-testing&quot;&gt;Why not include traditional testing?&lt;/h3&gt;

&lt;p&gt;Arguably, TDD was the original “closing the loop” in software development. TDD has never caught on in the iOS world.&lt;/p&gt;

&lt;p&gt;I dabbled with an actual Swift Testing-based testing flow for another &lt;a href=&quot;/2025/12/25/shinkansen-live-developing-the-app-for-ios/#ocr-and-parsing-the-ticket-image&quot;&gt;recent project&lt;/a&gt;, and even wrote about another experimental system a few months ago in &lt;a href=&quot;/2025/07/13/giving-claude-code-eyes-to-see-your-swiftui-views/&quot;&gt;Giving Claude Code Eyes to See Your SwiftUI Views&lt;/a&gt; that used snapshot testing. What I found was although tests are great for verifying correct behavior over the long term, in the short term they are super slow on iOS:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Installing and launching requires instantiating a brand new simulator for each run (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Clone 1&lt;/code&gt;), which takes a long time.&lt;/li&gt;
  &lt;li&gt;All builds are clean builds (this could have just been a fluke in my setup at the time though).&lt;/li&gt;
  &lt;li&gt;Swift Testing does not output failures in a way that Claude can read and iterate on (again, potentially solvable).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Admittedly, I didn’t spend as much time debugging these flows. Hopefully someone else will fill in the blanks for testing and write this guide.&lt;/p&gt;

&lt;h3 id=&quot;dont-sleep-on-simctl&quot;&gt;Don’t sleep on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simctl&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simctl&lt;/code&gt; CLI we’ve used throughout this post has a ton of other abilities that Claude can use to make our lives easier. This includes adding images to Photos, changing the system time, changing the system language, resetting privacy, resetting the keychain, and many more.&lt;/p&gt;

&lt;p&gt;Ask Claude to configure your simulator on the fly instead of clicking through settings with your mouse.&lt;/p&gt;

&lt;h3 id=&quot;what-does-the-future-hold&quot;&gt;What does the future hold?&lt;/h3&gt;

&lt;p&gt;I’m honestly not sure how long the hard-won knowledge in this post will be relevant, given the pace of model &amp;amp; harness capabilities. Peter Steinberger &lt;a href=&quot;https://steipete.me/posts/2025/shipping-at-inference-speed&quot;&gt;already says&lt;/a&gt; Codex is good enough and doesn’t need any additional guidance about build commands or working with the simulator.&lt;/p&gt;

&lt;p&gt;I can definitely see a world where Claude Code has a live feed of the simulator output it can process and react to at 60 fps, tapping and swiping with full accuracy. This is probably what’s missing in fully closing the development loop on iOS. Doing the same for a real device hopefully isn’t close behind.&lt;/p&gt;

&lt;p&gt;At that point though, I’m not sure what else about development will have changed.&lt;/p&gt;

&lt;h3 id=&quot;going-forward&quot;&gt;Going forward&lt;/h3&gt;

&lt;p&gt;Most of the material in this guide has been slowly compiled over the month in my various CLAUDE.md files. It was great getting a chance to formalize it even if I can’t make a quickly installable Plugin or Skill to share (hopefully you understand why after reading the post). I’m looking forward to seeing how far I can take each of these steps in the near future.&lt;/p&gt;

&lt;h3 id=&quot;corrections&quot;&gt;Corrections&lt;/h3&gt;

&lt;p&gt;Please reach out if you find any corrections or can contribute any additional knowledge or edge cases.&lt;/p&gt;

</description>
        <pubDate>Sat, 27 Dec 2025 15:37:01 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/27/closing-the-loop-on-ios-with-claude-code/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/27/closing-the-loop-on-ios-with-claude-code/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>claudecode</category>
        
        
      </item>
    
      <item>
        <title>Shinkansen Live - Developing the App for iOS</title>
        <description>&lt;p&gt;In my &lt;a href=&quot;/2025/12/24/shinkansen-live-scan-your-ticket-get-a-live-activity/&quot;&gt;last post&lt;/a&gt; I introduced the motivation and feature set of &lt;a href=&quot;https://apps.apple.com/app/id6756808516&quot;&gt;Shinkansen Live&lt;/a&gt;, my latest iOS app. I encourage you to read that one first to learn about what the app does.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Shinkansen Live app icon&quot; title=&quot;Shinkansen Live app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Shinkansen Live app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In this post, I’ll discuss a few of the interesting development challenges I faced during its week of development from concept to App Store release.&lt;/p&gt;

&lt;h2 id=&quot;contents&quot;&gt;Contents&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#overall-development-strategy&quot;&gt;Overall development strategy&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#ocr-and-parsing-the-ticket-image&quot;&gt;OCR and parsing the ticket image&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#live-activities&quot;&gt;Live Activities&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#animations&quot;&gt;Animations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#alarmkit&quot;&gt;AlarmKit&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#localization&quot;&gt;Localization&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#dynamic-type&quot;&gt;Dynamic Type&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#visionkit-camera&quot;&gt;VisionKit Camera&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;overall-development-strategy&quot;&gt;Overall development strategy&lt;/h2&gt;

&lt;p&gt;I created an Xcode project myself with Xcode 26.1 (later switching to Xcode 26.2), then added the &lt;a href=&quot;https://github.com/pointfreeco/swift-composable-architecture&quot;&gt;TCA&lt;/a&gt; package.&lt;/p&gt;

&lt;p&gt;Then, I set off to work using Claude Code with Opus 4.5. I started by having it lay out the SwiftUI View and TCA Feature without any logic. Then I built out the rest of the infrastructure around getting the input image, doing OCR, parsing the output, and displaying the results. I’ll go through more of the history later on in the post.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-early-layouts-4panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Early view layouts during initial development&quot; title=&quot;Early view layouts during initial development&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Early view layouts during initial development&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;ocr-and-parsing-the-ticket-image&quot;&gt;OCR and parsing the ticket image&lt;/h2&gt;

&lt;p&gt;The most difficult part of getting this app to production was the ticket OCR &amp;amp; parsing system. This system went through the most churn over the week, partially due to expanding scope and partially due to my expanding understanding of the problem space.&lt;/p&gt;

&lt;p&gt;My initial thought during the prototyping stage was to target only the “ticket” screenshot from Eki-net you get after purchase. It looks something like this:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-example-ekinet.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example Eki-net ticket screenshot&quot; title=&quot;Example Eki-net ticket screenshot&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example Eki-net ticket screenshot&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In theory, it’d be reasonable to limit the app’s input space to app screenshots from Eki-net (JR-East) and SmartEX (JR-central). Even including web browser screenshots wouldn’t be that much more burden on an OCR-based system. But later on in the project when I’d decided I was happy enough with the prototype that I wanted to target a production release on the App Store, I started thinking about how it would make marketing much harder to say “only works on screenshots” and not physical tickets.&lt;/p&gt;

&lt;h3 id=&quot;why-ocr-why-not-multi-modal-llms&quot;&gt;Why OCR? Why not multi-modal LLMs?&lt;/h3&gt;

&lt;p&gt;OCR via &lt;a href=&quot;https://developer.apple.com/documentation/visionkit&quot;&gt;VisionKit&lt;/a&gt; alongside manual parsing has a lot of upsides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Free for user &amp;amp; developer&lt;/li&gt;
  &lt;li&gt;Fast&lt;/li&gt;
  &lt;li&gt;Multilingual&lt;/li&gt;
  &lt;li&gt;Privacy baked in&lt;/li&gt;
  &lt;li&gt;No network usage&lt;/li&gt;
  &lt;li&gt;Relatively mature: less risk of accuracy churn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In theory, multimodal LLMs can handle OCR and understanding more variations of tickets layouts and bad lighting. In fact, while I was writing the parser, I used Opus 4.5 to read the test ticket images in order to create the ground truth test expectation data.&lt;/p&gt;

&lt;p&gt;My issue with prototyping with LLMs further was that they had essentially the opposite pros and cons as the VisionKit system:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Unknown, but some ongoing cost (meaning I’d need to come up with a monetization strategy before release)&lt;/li&gt;
  &lt;li&gt;Unknown which level of model would correctly balance accuracy, cost, and speed over the short term.&lt;/li&gt;
  &lt;li&gt;Requires network access&lt;/li&gt;
  &lt;li&gt;Requires sending photo data off device (not hugely private, but still)&lt;/li&gt;
  &lt;li&gt;Different outputs for the same input&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I didn’t explore directly was using the Apple Foundation model at the &lt;em&gt;parsing&lt;/em&gt; layer. The current Apple Foundation model in iOS 26 has no image input API, but I could perhaps prompt it to take the raw output of the VisionKit model and try to make sense of it. My instinct is that this would be a waste of time, but still worth keeping on the table.&lt;/p&gt;

&lt;h3 id=&quot;overall-strategy&quot;&gt;Overall strategy&lt;/h3&gt;

&lt;p&gt;While working on this, I honestly wasn’t thinking strictly in terms of prototype &amp;amp; production. From the first spark of idea I had an understanding of what the overall UX flow of the app would be. It was mostly getting to an answer of “is this feasible to productionize in a couple days?” while still being flexible on the scope of what &lt;em&gt;production-ready&lt;/em&gt; meant.&lt;/p&gt;

&lt;p&gt;That meant that I started by adding my ticket screenshot to the project, giving Claude my overall strategy, and having Claude create the OCR &amp;amp; parser system that output results directly into the UI.&lt;/p&gt;

&lt;h3 id=&quot;ocr-prototype&quot;&gt;OCR prototype&lt;/h3&gt;

&lt;p&gt;The first prototype parser supported just the two screenshots I had of Eki-net tickets (one from the morning of my trip; another from a similar trip a few months ago).&lt;/p&gt;

&lt;p&gt;The implementation set up a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; in Japanese language mode, read out the highest ranking results into several lines of text (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt;), then fed that to Claude’s homegrown parser that pulled the ticket attributes out of that glob of text mostly using regex.&lt;/p&gt;

&lt;p&gt;Since the input was from a perfectly legible screenshot, there was no issues with the VisionKit part nor the parser.&lt;/p&gt;

&lt;h3 id=&quot;ocr-for-real-shinkansen-ticket-images&quot;&gt;OCR for real Shinkansen ticket images&lt;/h3&gt;

&lt;p&gt;As soon as I tested the system on a real Shinkansen ticket in a photo, the system fell apart.&lt;/p&gt;

&lt;p&gt;I searched through my personal Photo library for as many Shinkansen tickets as I could find. I googled for more. I started with 4 images (and later in development I ended up with about 10 images).&lt;/p&gt;

&lt;p&gt;At first I was simply trying to naively patch out the parser with Claude. To do this, I set up a unit test system where I’d do the VisionKit request for each image once and write the resulting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; data structure to disk. Then each unit test would read that data in, run the parser, and compare the expected ticket structure to the test result.&lt;/p&gt;

&lt;p&gt;Unintuitively, the standard Swift Testing unit test setup was actually the less efficient way to iterate on this. Claude was having a lot of trouble reading the detailed test failure information after it ran xcodebuild in the command line. Each build &amp;amp; run &amp;amp; test iteration needed to boot up a fresh simulator, install the app, run the test, then tear down the simulator.&lt;/p&gt;

&lt;p&gt;Instead, a built a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main&lt;/code&gt; App-based test harness that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;temporarily disabled the real UI.&lt;/li&gt;
  &lt;li&gt;ran the test code on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;printed the test results via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; statements.&lt;/li&gt;
  &lt;li&gt;called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit(0)&lt;/code&gt; when finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For quickly prototyping, iterating, and understanding the scope of the problem space, this solved all the issues with the Swift Testing setup. The same booted simulator was reused on each run. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData&lt;/code&gt; and build artifacts were reused, so builds were fast. Claude had no trouble reading print statements from the console output.&lt;/p&gt;

&lt;p&gt;I let Claude run in its own loop for a while to see what it could and couldn’t improve with the parser based on the limitations of our system.&lt;/p&gt;

&lt;p&gt;Claude found several underlying limitations with the VisionKit setup that were unsolvable at the parser level.&lt;/p&gt;

&lt;p&gt;For example, concatenating all the text recognition objects into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; was done somewhat naively by comparing y-coordinates. If the y-coordinates of objects were within a certain range, they were assumed to be on the same line. When the ticket was tilted, this strategy was interleaving text.&lt;/p&gt;

&lt;p&gt;Additionally, some numbers and letters were just flat out being interpreted incorrectly. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;37&lt;/code&gt; was read as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;30&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CAR&lt;/code&gt; was read as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CDR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Off running on its own, Claude was trying to special case as much of these failure cases it could to get the tests passing.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-test-ticket-04.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&quot; title=&quot;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;CC制
東
3月18日（
はくたから5り考
¥3,380
新幹線特急券
京
→
8:41発）
軽井
（9:4着）
7号車
3番B席
R001
2025.-3.18東京北乗FN7（2－）
50159-01
沢
0
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Comparing some of the fields, you can see the OCR output taken naively is not great:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The departure time is there but the arrival time is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9:4&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9:43&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The arrival station’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;軽井&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;沢&lt;/code&gt; are split up and should be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;軽井沢&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The departure station’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;東&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;京&lt;/code&gt; are split up and should be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;東京&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The train name and number are a mess: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;はくたから5り考&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;はくたか 555号&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;using-spatial-data&quot;&gt;Using spatial data&lt;/h3&gt;

&lt;p&gt;The OCR part of the system was trying to abstract away the spatial parts from the parser. Looking at the raw data, my intuition was that the spacial data could be useful within the parsing layer. Instead of passing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; between layers, I was now passing:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Each field parser within the parsing system could decide for itself how to use the spacial data. This is especially useful considering how important &lt;em&gt;anchor values&lt;/em&gt; are. For example “発” (indicating a departure time) and “→” (indicating the station to the left is the departure station).&lt;/p&gt;

&lt;p&gt;This improved things a bit for 5 ticket images, but after adding another 5 and going all in on English ticket support, there were plenty more edge cases to consider.&lt;/p&gt;

&lt;h3 id=&quot;transforms&quot;&gt;Transforms&lt;/h3&gt;

&lt;p&gt;Only after seeing some wonky positioning of the text boxes in my loading animation (see below), I realized that I wasn’t accounting for the image transform properly when converting the input &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIImage&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CGImage&lt;/code&gt; as input to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGImagePropertyOrientation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;uiOrientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UIImage&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Orientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uiOrientation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;up&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;up&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;upMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;down&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;down&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;downMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;downMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;left&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;left&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;leftMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leftMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;right&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;right&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;rightMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rightMirrored&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;@unknown&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;up&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;levenshtein-distance&quot;&gt;Levenshtein distance&lt;/h3&gt;

&lt;p&gt;Like our previous example of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CDR&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CAR&lt;/code&gt;, VisionKit was reading strings like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKIO&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKYO&lt;/code&gt;. For these parts, I figured calculating &lt;a href=&quot;https://en.wikipedia.org/wiki/Levenshtein_distance&quot;&gt;Levenshtein distance&lt;/a&gt; from known strings was the right strategy. The Shinkansen system is large but not so large that I couldn’t ingest the station name values and the other known strings like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAN&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FEB&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;One strategy I haven’t tested yet is using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest.customWords&lt;/code&gt; with the full dictionary of station names, etc. to see if that eliminates the need for using Levenshtein distance at all.&lt;/p&gt;

&lt;h3 id=&quot;english-and-japanese-mode-ocr&quot;&gt;English and Japanese mode OCR&lt;/h3&gt;

&lt;p&gt;For some background, tickets can be printed in an “English” variant that includes a mix of English and Japanese text. If you buy from a ticket vending machine and complete the purchase in English mode, it’ll print an English ticket. Similarly, if you buy from a human ticket vendor at the counter, they will print your ticket in English variant if you speak English to them.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-example-english-ticket.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example of an English variant Shinkansen ticket&quot; title=&quot;Example of an English variant Shinkansen ticket&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example of an English variant Shinkansen ticket&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;At the VisionKit layer, I was first using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; in Japanese-mode only.&lt;/p&gt;

&lt;p&gt;I tried expanding a single instance to include both English and Japanese text. But checking the raw results, a dual language setup severely impaired its abilities.&lt;/p&gt;

&lt;p&gt;For a while, I had a dual parsing system that would check for a few English strings, and if any were found, it would assume the ticket was an “English ticket” and run a separate English &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; and return those results. This didn’t work well for a few reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Japanese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; was surprisingly bad at reading numbers compared to the English one.&lt;/li&gt;
  &lt;li&gt;Deciding at the VisionKit layer which text results should come from the English request didn’t make a lot of sense conceptually if I wanted to keep the majority of the logic in the parser layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Therefore, I decided to run both the English and Japanese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt;s on every input and provide all the results to the parser.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;BilingualOCRResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;jp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;en&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I was also previously discarding the confidence score (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.0...1.0&lt;/code&gt;) that VisionKit provides with each observation. This score was actually different in the parallel observations for English and Japanese in some cases, especially number recognition. I added the confidence score to the output so the parser could use it.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;confidence&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;unparseable-fields-fallback-ux&quot;&gt;Unparseable fields fallback UX&lt;/h3&gt;

&lt;p&gt;At this point in the development of the parser, I was pretty certain reading photos of tickets was never going to be reach 100% accuracy for every field.&lt;/p&gt;

&lt;p&gt;I took a break from working on the parser to implement editing for every field in the ticket UI. This meant that users could manually recover from parsing errors and omissions.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-editing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Editing screen with editable ticket fields&quot; title=&quot;Editing screen with editable ticket fields&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Editing screen with editable ticket fields&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Not needing to reach 100% accuracy in the parser while still ensuring the user gets value out of the system as a whole opened up a more reasonable strategy for the parser.&lt;/p&gt;

&lt;h3 id=&quot;defense-in-depth-parsing-system&quot;&gt;Defense-in-depth parsing system&lt;/h3&gt;

&lt;p&gt;After tweaking more and more of the VisionKit layer, I had to regenerate the VisionKit output test data for each test ticket image, and then essentially rewrite the parser layer.&lt;/p&gt;

&lt;p&gt;This time, the strategy was to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Pass in the full English and Japanese results including relative x and y coordinates to each field.&lt;/li&gt;
  &lt;li&gt;Create a sub-parser dedicated to each field of the ticket that needed to be parsed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Within each sub-parser, my strategy was to start with the best case scenario of input data quality, then step-by-step keep loosening the guidelines to account for more unideal cases that had come up in the test data, then finally falling back to returning &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; for that field.&lt;/p&gt;

&lt;h3 id=&quot;ocr-testing-system&quot;&gt;OCR testing system&lt;/h3&gt;

&lt;p&gt;Claude iterated on the field parser implementations for an hour or two, one at a time, checking the test output to ensure there were no regressions along the way.&lt;/p&gt;

&lt;p&gt;At a certain point all the tests were passing and as much as I wanted to keep finding test data and tweaking the parser, I knew I had to move on.&lt;/p&gt;

&lt;h2 id=&quot;live-activities&quot;&gt;Live Activities&lt;/h2&gt;

&lt;p&gt;During development I had to keep reminding myself that the whole point of this endeavor was to have a slick (read: &lt;em&gt;useful&lt;/em&gt;) Live Activity.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-live-activity-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; title=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity in Dynamic Island compact, expanded, and lock screen views&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My design process was quick and to-the-point:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;list out all the Live Activity contexts: lock screen, dynamic island compact leading, compact trailing, minimal, and expanded.&lt;/li&gt;
  &lt;li&gt;consider all the ticket info I had available from the parser output.&lt;/li&gt;
  &lt;li&gt;consider all of the above for the “before” and “during” trip phases (if I knew I had more accurate control over Live Activity update timing, I might have divided these phases up even further).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In lock screen and expanded contexts, you can &lt;em&gt;mostly&lt;/em&gt; display everything you want. The challenge is in aesthetics and visual hierarchy like in any design.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-lockscreen-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Lock screen Live Activity showing trip details&quot; title=&quot;Lock screen Live Activity showing trip details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Lock screen Live Activity showing trip details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When dealing with the compact and minimal contexts, you really do only have the equivalent of about 6 very small characters to work with, and 2 lines if you want to push your luck. If you try to fill the entire available space of the Dynamic Island on either side, you’ll lose the system clock which is no go for my use case.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-compact-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Compact Live Activity in Dynamic Island&quot; title=&quot;Compact Live Activity in Dynamic Island&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Compact Live Activity in Dynamic Island&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When I thought about it, the most important of all the contexts was the compact leading and trailing in the &lt;em&gt;before&lt;/em&gt; trip phase. This is when the information is most needed at a glance.&lt;/p&gt;

&lt;p&gt;I stacked in departure time and train number in the compact leading. This information is used to decide when to go to the platform and which platform to go to.&lt;/p&gt;

&lt;p&gt;I stacked the car and seat number in the compact trailing. These are used to decide where to line up on the platform and of course where you’ll sit.&lt;/p&gt;

&lt;h3 id=&quot;view-guidelines-and-tips&quot;&gt;View guidelines and tips&lt;/h3&gt;

&lt;p&gt;Designing for Live Activities is painful. There are significantly more constraints to the SwiftUI View system than in normal app contexts. Most are undocumented. Some quirks can be teased out in the SwiftUI Preview if you’re lucky. Others only appear on the simulator or a real device.&lt;/p&gt;

&lt;p&gt;I have a couple guidelines and tips I follow for Live Activities.&lt;/p&gt;

&lt;h4 id=&quot;use-non-semantic-font-sizes-for-compact-and-minimal&quot;&gt;Use non-semantic font sizes for compact and minimal&lt;/h4&gt;

&lt;p&gt;The system ignores Dynamic Type settings in the compact and minimal Dynamic Island contexts. Using point sizes directly gives more flexibility while designing in the very limited space.&lt;/p&gt;

&lt;p&gt;In the lock screen and expanded contexts, there’s limited Dynamic Type support (4-levels total), so it’s still worth using semantic fonts as usual (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.headline&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.title3&lt;/code&gt;).&lt;/p&gt;

&lt;h4 id=&quot;for-dynamically-updating-times-prepare-to-spend-a-lot-of-time-in-trial-and-error&quot;&gt;For dynamically updating times, prepare to spend a lot of time in trial and error&lt;/h4&gt;

&lt;p&gt;Maybe someday I’ll write a full explainer post on which countdown-style &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; fields are supported. In short, if you want dynamically updated fields in any part of your Live Activity, your formatting options are limited and underdocumented.&lt;/p&gt;

&lt;p&gt;A couple configurations I used:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// `44 min, 23 sec` or `1 hr, 52 min`
Text(attributes.departureTime, style: .relative)

// Centers `Departing in N min, M, sec` due to `.relative`&apos;s implicit `maxWidth: .infinity`
HStack(spacing: 4) {
    Text(String(localized: &quot;widget.departing-in&quot;, comment: &quot;Footer label shown before departure&quot;))
        .frame(maxWidth: .infinity, alignment: .trailing)
    Text(attributes.departureTime, style: .relative)
}

// Linear progress view with no label
ProgressView(timerInterval: attributes.departureTime ... attributes.arrivalTime, countsDown: false)
    .progressViewStyle(.linear)
    .labelsHidden()
   
// `60:00`
Text(
     timerInterval: (Date.now)...(Date(timeIntervalSinceNow: 60*60)),
     pauseTime: Date(timeIntervalSinceNow: 60*60),
     countsDown: true,
     showsHours: false
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;As noted above, any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; using: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text(Date(), style: .relative)&lt;/code&gt; will expand to fill its full width.&lt;/p&gt;

&lt;p&gt;It’s frustrating just thinking about this again. I basically just banged my head against the wall until I landed on a design I felt embarrassed but comfortable shipping.&lt;/p&gt;

&lt;h4 id=&quot;clamped-width-custom-layout&quot;&gt;Clamped width custom Layout&lt;/h4&gt;

&lt;p&gt;I use this custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Layout&lt;/code&gt; judiciously in the Dynamic Island.&lt;/p&gt;

&lt;p&gt;It makes the underlying view’s frame collapse to fit its ideal width, but clamped to a maximum value.&lt;/p&gt;

&lt;p&gt;I want the compact or minimal context to be as narrow as possible. I want short input text to result in a very narrow Dynamic Island layout. I want longer input not to expand beyond a certain width; and even more, I want to use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;minimumScaleFactor&lt;/code&gt; modifier to further shrink the text size once that maximum width is reached.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SingleViewClampedWidthLayout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Layout&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProposedViewSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;inout&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGSize&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subview&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;zero&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;idealWidth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;width&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;idealWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;placeSubviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGRect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProposedViewSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;inout&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subview&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ClampedWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ViewModifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;SingleViewClampedWidthLayout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@ViewBuilder&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;clamped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;modifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ClampedWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;updating-in-background&quot;&gt;Updating in background&lt;/h3&gt;

&lt;p&gt;Live Activities can usually only be updated via remote Push Notification. But if your app gets a chance to wake up and run in the background, it can also issue updates to the Live Activity.&lt;/p&gt;

&lt;p&gt;One of the few reliable ways to have your app woken up in the background regularly is to use significant location updates from Core Location. In order to have your app get background time you need to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Be approved by the user for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Always&lt;/code&gt; Location Services permission.&lt;/li&gt;
  &lt;li&gt;Be approved by the user for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WhenInUse&lt;/code&gt; Location Services permission AND one of the following
    &lt;ul&gt;
      &lt;li&gt;Have an active Live Activity OR&lt;/li&gt;
      &lt;li&gt;Start a CLBackgroundActivitySession (that essentially creates a default Live Activity)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the case of Shinkansen Live, I have two phases for the Live Activity:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;before the train departs&lt;/li&gt;
  &lt;li&gt;after the train departs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The time of that change over is pretty reliably scheduled. But Live Activities have no update schedule like Widgets do (for some reason).&lt;/p&gt;

&lt;p&gt;To update the Live Activity after the train departs I could:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Have the user tap a button in the lock screen or expanded Live Activity that triggers an intent to wake up the app in the background and update the Live Activity.&lt;/li&gt;
  &lt;li&gt;Hope the user opens the app on their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of these options are unideal, so instead I ask for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WhenInUse&lt;/code&gt; Location Services permission and start a monitoring for significant locations. One of these will be fired not long after the train departs (within about 1km or 5 minutes). That trigger will open the app in the background, update the Live Activity based on the current time, then go back to sleep.&lt;/p&gt;

&lt;h3 id=&quot;persisting-the-trip&quot;&gt;Persisting the trip&lt;/h3&gt;

&lt;p&gt;There’s an edge case I wanted to handle with the Live Activity lifetime.&lt;/p&gt;

&lt;p&gt;If the system kills the app in the middle of a trip, the Live Activity in theory should continue uninterrupted since it’s an App Extension. But in the case of Shinkansen Live, I’m expecting to update the Live Activity while the app is backgrounded. This means there’s a potential flow where:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app is in the background with the Live Activity running.&lt;/li&gt;
  &lt;li&gt;The app is killed by the system.&lt;/li&gt;
  &lt;li&gt;The Live Activity continues to run.&lt;/li&gt;
  &lt;li&gt;The system cold launches the app in the background.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point, I could decide to query the Live Activities framework to see if there’s a Live Activity running and if so, restore the ticket model layer and UI. However, I prefer not to treat the Live Activity as the source of truth for the model layer.&lt;/p&gt;

&lt;p&gt;I added support with the &lt;a href=&quot;https://github.com/pointfreeco/swift-sharing&quot;&gt;Sharing&lt;/a&gt; library to persist the ticket model automatically on changes. On the above flow, I use the persisted ticket model to restore the UI and Live Activity and AlarmKit state, ensuring the ticket data is still valid.&lt;/p&gt;

&lt;h3 id=&quot;handling-dismissal&quot;&gt;Handling dismissal&lt;/h3&gt;

&lt;p&gt;One final bit of UX that’s not mission critical but is very user friendly is to respond to user-initiated Live Activity dismissals from the lock screen. If the user swipes from right to left on your Live Activity, the system dismisses it. When your app next runs, it will receive an update from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Activity.activityStateUpdates&lt;/code&gt; stream (if you’re monitoring it).&lt;/p&gt;

&lt;p&gt;If the app detects a user-initiated dismissal, I consider that their trip has ended, clear the ticket, and go back to the app home screen. There’s an argument that it’d be safer to simply toggle the Live Activity, but since my app’s only purpose is to show a Live Activity, I don’t think it makes sense to build in more complexity to keep multiple states.&lt;/p&gt;

&lt;h2 id=&quot;animations&quot;&gt;Animations&lt;/h2&gt;

&lt;p&gt;The focused nature of this app allowed me a bit more breathing room to experiment with custom screen transitions and multi-stage animations.&lt;/p&gt;

&lt;h3 id=&quot;root-level-transitions&quot;&gt;Root level transitions&lt;/h3&gt;

&lt;p&gt;At the bare minimum, I usually try to use a default opacity transition for root level views when they aren’t covered by system transitions like a navigation push or sheet presentation.&lt;/p&gt;

&lt;p&gt;For Shinkansen Live, I added a little bit of extra scale effect to the usual opacity transition of the initial screen, both on cold launch and when returning from the trip screen.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-initial-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-initial-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;p&gt;For the trip screen, I first animated the card down from the top with some scale and opacity, then fade in the other sections with some scale.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-trip-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-trip-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;scanning-animation&quot;&gt;Scanning animation&lt;/h3&gt;

&lt;p&gt;The most fun was doing the scanning animation. Once the selected image is downloaded and displayable, I animate it in with a bit of 3D effect. Then I use the coordinate results of the text observations to animate those boxes onto the image.&lt;/p&gt;

&lt;p&gt;This animation serves a few purposes in my opinion:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Visually expresses to the user what the app is actually doing.&lt;/li&gt;
  &lt;li&gt;Buys time for the parser to do its job.&lt;/li&gt;
  &lt;li&gt;Feels fun and playful in a way that is motivating for users to want to go through the trouble of submitting their ticket image.&lt;/li&gt;
&lt;/ul&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-scanning-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-scanning-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;ticket-image-modal-animation&quot;&gt;Ticket image modal animation&lt;/h3&gt;

&lt;p&gt;My final bit of (self) user testing made me realize that even though I’d built in a way to update ticket values that were missing or erroneously parsed, I had no in-app UI for actually doing the field checking. Depending on their input source, the user would have to hold up their physical ticket next to the app’s virtual ticket to double check the fields. Or if they’d used a screenshot, they’d have to flip back and forth between Photos app.&lt;/p&gt;

&lt;p&gt;As my last big task, I added support for showing the original image inline with the ticket in a modal overlay.&lt;/p&gt;

&lt;p&gt;This setup uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;matchedGeometryEffect&lt;/code&gt; and was a nightmare to work through. In the end it’s not perfect, but the speed conceals some of the jankiness. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;matchedGeometryEffect&lt;/code&gt; has a lot of undocumented incompatibilities with other modifiers, so it was just hours upon hours of reordering modifiers and building and running to check what had changed. I came out of the experience with little new demonstrably true observations I can share here, unfortunately.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-image-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-image-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;card-dragging&quot;&gt;Card dragging&lt;/h3&gt;

&lt;p&gt;Whenever there’s a card-looking UI on screen, I want it to be interactable even if there’s no real gesture that makes sense.&lt;/p&gt;

&lt;p&gt;I created a custom drag gesture with rubberbanding that allows the user to drag the ticket a little bit in any direction. When released, it snaps back with a custom haptic that mirrors the visual.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-card-drag.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h2 id=&quot;alarmkit&quot;&gt;AlarmKit&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/alarmkit&quot;&gt;AlarmKit&lt;/a&gt; is new in iOS 26 and I thought it might be a good fit for Shinkansen Live’s use case.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-alarm-2panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alarm setting in trip screen and full screen alarm notification&quot; title=&quot;Alarm setting in trip screen and full screen alarm notification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alarm setting in trip screen and full screen alarm notification&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The integration was mostly straightforward, but it added another layer of complexity to the reducer implementation to ensure that it was added, changed, and removed for all the relevant cases:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Arrival time exists or doesn’t exist.&lt;/li&gt;
  &lt;li&gt;Arrival time is updated manually by the user.&lt;/li&gt;
  &lt;li&gt;System time is too close to arrival time to set an alarm.&lt;/li&gt;
  &lt;li&gt;Journey is ended by the user before arrival time.&lt;/li&gt;
  &lt;li&gt;Live Activity is dismissed from the lock screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And more.&lt;/p&gt;

&lt;h3 id=&quot;permission-dialog&quot;&gt;Permission dialog&lt;/h3&gt;

&lt;p&gt;A last minute annoyance with AlarmKit was testing localization: I &lt;a href=&quot;https://hachyderm.io/@twocentstudios/115740319076548675&quot;&gt;found a bug&lt;/a&gt; where the localized text for the AlarmKit permissions dialog was not being used on iOS 26.0. But the bug was fixed for iOS 26.1. And no, the bug nor the fix were mentioned in any official SDK release notes.&lt;/p&gt;

&lt;h2 id=&quot;localization&quot;&gt;Localization&lt;/h2&gt;

&lt;p&gt;One of my favorite usages for coding agents is doing Localization setup. Note I’m specifically &lt;em&gt;not&lt;/em&gt; talking about LLMs doing the actual translation, but instead:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;doing the initial conversion from inline strings to string keys e.g, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;loaded.end-journey-button&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;adding localizer comments to each string key e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Button to end the current journey&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;maintaining default values for when string interpolations are required e.g.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;localized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;loaded.arrival-alert.status.minutes-before&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;defaultValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mins&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; min before&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Status showing minutes before arrival (for menu items)&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The initial conversion uses a custom markdown document with some basic rules. It takes about an hour for the first run and then a few more passes with a human in the loop to ensure the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; file is clean.&lt;/p&gt;

&lt;h2 id=&quot;dynamic-type&quot;&gt;Dynamic Type&lt;/h2&gt;

&lt;p&gt;The app still lays out pretty well with most levels of Dynamic Type. I only use semantic font qualifiers. All content is in a scroll view that’s usually fixed.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-dynamic-type-landing.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Landing screen with various Dynamic Type sizes&quot; title=&quot;Landing screen with various Dynamic Type sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Landing screen with various Dynamic Type sizes&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-dynamic-type-trip.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Trip screen with various Dynamic Type sizes&quot; title=&quot;Trip screen with various Dynamic Type sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Trip screen with various Dynamic Type sizes&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;visionkit-camera&quot;&gt;VisionKit Camera&lt;/h2&gt;

&lt;p&gt;I’m using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNDocumentCameraViewController&lt;/code&gt; as the integrated camera view for scanning. The UX is a little weird because there’s no way to limit the input (output?) to one photo. The result is the user can take a bunch of photos of their ticket before they tap “Done” and the app will only read the first.&lt;/p&gt;

&lt;h2 id=&quot;project-stats&quot;&gt;Project stats&lt;/h2&gt;

&lt;h3 id=&quot;by-the-numbers&quot;&gt;By the numbers&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Development time: &lt;strong&gt;~6 days&lt;/strong&gt; including App Store materials&lt;/li&gt;
  &lt;li&gt;Lines of Swift (excluding tests, static data, etc.): &lt;strong&gt;8,388&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;daily-devlog&quot;&gt;Daily Devlog&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Dec 16, 2025 — Initial layout &amp;amp; OCR foundation&lt;/li&gt;
  &lt;li&gt;Dec 17, 2025 — Live Activities, AlarmKit, failure states&lt;/li&gt;
  &lt;li&gt;Dec 18, 2025 — Bilingual OCR, loading animation, app icon, localization&lt;/li&gt;
  &lt;li&gt;Dec 19, 2025 - Parser rewrite, settings view&lt;/li&gt;
  &lt;li&gt;Dec 20, 2025 — Polish, persistence, supported formats view, App Store prep&lt;/li&gt;
  &lt;li&gt;Dec 23, 2025 — Ticket image modal, App Store materials, v1.0 Release&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Part of the appeal of this idea is that the scope could only creep so much. I honestly didn’t leave much on the TODO list for a version 1.1 besides endless optimization potential for the parser.&lt;/p&gt;

&lt;p&gt;As usual this post was brain-dump style. If there’s any part you connected with and would like me to explore further, feel free to give me a shout.&lt;/p&gt;
</description>
        <pubDate>Thu, 25 Dec 2025 07:41:42 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/25/shinkansen-live-developing-the-app-for-ios/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/25/shinkansen-live-developing-the-app-for-ios/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>app</category>
        
        <category>shinkansenlive</category>
        
        
      </item>
    
      <item>
        <title>Shinkansen Live: Scan Your Ticket, Get a Live Activity</title>
        <description>&lt;p&gt;Today I’m releasing my latest iOS app: Shinkansen Live or 新幹線ライブ in Japanese.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://apps.apple.com/app/id6756808516&quot;&gt;Shinkansen Live on the App Store&lt;/a&gt;&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Shinkansen Live app icon&quot; title=&quot;Shinkansen Live app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Shinkansen Live app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The concept is simple: you scan your Shinkansen ticket or receipt and you can see the details of your trip in a Live Activity on your lock screen and Dynamic Island.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scan-flow-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scan flow in 3 panels: scanning, ticket, lock screen&quot; title=&quot;Scan flow in 3 panels: scanning, ticket, lock screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scan flow in 3 panels: scanning, ticket, lock screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Here’s a quick screen capture of the main flow:&lt;/p&gt;

&lt;video poster=&quot;/images/shinkansen-v1-app-preview-poster.png&quot; controls=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-app-preview.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h2 id=&quot;motivation&quot;&gt;Motivation&lt;/h2&gt;

&lt;p&gt;I took a quick Shinkansen trip from Omiya (north Tokyo) to Karuizawa (Nagano) last week to do some co-working with my friends Jens and David. I had pre-purchased a reserved seat with the &lt;a href=&quot;https://www.eki-net.com/en/jreast-train-reservation/Top/Index&quot;&gt;Eki-net&lt;/a&gt;, JR-East’s Shinkansen app (on iOS, it’s a web app wrapper). Similar to when I have a physical ticket (but somehow worse?) I found myself opening the app repeatedly to check my ticket’s listed attributes for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;departure time: in the 30 minutes or so lead up, before I’d entered the gates.&lt;/li&gt;
  &lt;li&gt;train number: to cross reference and check the platform I should leave from.&lt;/li&gt;
  &lt;li&gt;car number: when it was time to ascend to the platform and look for where on the platform I should line up.&lt;/li&gt;
  &lt;li&gt;seat number: when the train pulled up and I was boarding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of my train apps &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt; and &lt;a href=&quot;/2025/06/03/eki-live-announcement/&quot;&gt;Eki Live&lt;/a&gt; have Live Activities support that I use frequently. I only ride the Shinkansen a few times a year, but while riding up to Karuizawa that day, I wondered, &lt;strong&gt;couldn’t I just OCR the Shinkansen ticket info from screenshot and stuff it into a Live Activity?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And so as soon as I arrived at Sawamura Roastery in Karuizawa, I got to work on prototyping a new app. My goal was to have a prototype by the ride home. With some extra polish it ended up taking a few more days of work.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-sawamura-fireplace.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Co-working vibes at Sawamura Roastery in Karuizawa&quot; title=&quot;Co-working vibes at Sawamura Roastery in Karuizawa&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Co-working vibes at Sawamura Roastery in Karuizawa&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;

&lt;p&gt;The structure of the app is essentially a landing screen, a processing screen, and a trip-in-progress screen. The Live Activity requires its own multiple states and layouts of UI. For polish, I needed an error screen, an about screen, and a screen explaining what ticket formats are accepted.&lt;/p&gt;

&lt;h3 id=&quot;supported-input-formats&quot;&gt;Supported input formats&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-landing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Landing screen showing input options&quot; title=&quot;Landing screen showing input options&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Landing screen showing input options&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My initial scope was just handling screenshots from Eki-net (JR-East’s app) and SmartEX (JR-Central’s app), and in retrospect this probably would have better line to draw in the sand for version 1. However, I added support for scanning physical tickets too since the app seemed like it would be &lt;em&gt;too&lt;/em&gt; specialized without physical tickets, probably the majority use-case.&lt;/p&gt;

&lt;p&gt;And so, you can scan your physical ticket with the camera, choose a screenshot from the Photo Library, paste an image from the system pasteboard, or create an empty ticket if you want.&lt;/p&gt;

&lt;h3 id=&quot;ocr-and-parsing&quot;&gt;OCR and parsing&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scanning.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scanning a ticket with OCR in progress&quot; title=&quot;Scanning a ticket with OCR in progress&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scanning a ticket with OCR in progress&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As of version 1.0, the app uses on device VisionKit to recognize text in the image and custom algorithm to do error recovery and parse out the relevant attributes from the ticket. I’ll discuss the development aspects of this decision in a future post, but for now, I’ll say that the merits of using OCR over multi-modal LLMs are that OCR is very fast, maintains privacy, and is accurate enough for a V1.&lt;/p&gt;

&lt;h3 id=&quot;trip-in-progress&quot;&gt;Trip in-progress&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-trip-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Trip in-progress screen showing ticket details&quot; title=&quot;Trip in-progress screen showing ticket details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Trip in-progress screen showing ticket details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Once the ticket is scanned and parsed, you land on the trip screen for the remainder of your journey.&lt;/p&gt;

&lt;p&gt;I recreated a facsimile of the legendary Shinkansen ticket. While doing research into ticket formats, it was surprising to see how &lt;em&gt;different&lt;/em&gt; the information layouts are depending on where and by what means they are purchased, but the aesthetic is generally the same.&lt;/p&gt;

&lt;p&gt;For the case of physical tickets, parsing is imperfect, so I wanted to ensure users could recover from minor errors like a missing time or train number. Therefore, all fields are user editable by tapping.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-editing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Editing screen with editable ticket fields&quot; title=&quot;Editing screen with editable ticket fields&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Editing screen with editable ticket fields&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I also include the input image that a user can reference in an expanded view. This makes it easier to double check values and fix mistakes.&lt;/p&gt;

&lt;video poster=&quot;/images/shinkansen-v1-expand-photo-poster.png&quot; controls=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-expand-photo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;live-activity&quot;&gt;Live Activity&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-live-activity-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; title=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity in Dynamic Island compact, expanded, and lock screen views&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Finally, the whole point of all this is to have a functional Live Activity. The Live Activity has a &lt;em&gt;before&lt;/em&gt; and &lt;em&gt;during&lt;/em&gt; layout. Before departure we show the departure time, train number, car number, and seat number (for reserved seats). During the trip, we show the arrival station and time.&lt;/p&gt;

&lt;p&gt;Due to a technical limitation with Live Activities, I use Location Services to monitor for significant location changes in the background, and use that to wake up the app and update the Live Activity when the departure time has passed. On the technical side, this means I don’t need to run a push notification server or do any other networking from the app.&lt;/p&gt;

&lt;h3 id=&quot;arrival-alarm&quot;&gt;Arrival alarm&lt;/h3&gt;

&lt;p&gt;I’m a chronic sufferer of a disease called Scope Creep (this is a joke), so I couldn’t help but add an optional arrival alarm feature. This feature uses the new iOS 26 &lt;a href=&quot;https://developer.apple.com/documentation/AlarmKit&quot;&gt;AlarmKit framework&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-alarm-2panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alarm setting in trip screen and full screen alarm notification&quot; title=&quot;Alarm setting in trip screen and full screen alarm notification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alarm setting in trip screen and full screen alarm notification&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;animations-and-transitions&quot;&gt;Animations and transitions&lt;/h3&gt;

&lt;p&gt;I spent a unreasonable amount of time working on the animations and transitions for this app. Since there’s comparatively not a lot of screens or unique transitions to handle, it felt like a good opportunity to push the limits and make the upload experience more delightful. After all, there’s not a &lt;em&gt;ton&lt;/em&gt; of benefit to cost when you consider needing to download an app, and then screenshot or photo your ticket in order to get that slight benefit of not needing to unlock your phone or take your ticket out of your pocket. Hopefully some fun animations add to the motivations to get over that mental hump.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scanning-transitions.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scanning transitions and animations&quot; title=&quot;Scanning transitions and animations&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scanning transitions and animations&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-image-popup.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Image popup transition&quot; title=&quot;Image popup transition&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Image popup transition&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;outlook&quot;&gt;Outlook&lt;/h2&gt;

&lt;p&gt;Shinkansen Live is free on v1.0 release. I have no idea whether the App Store listing will get views, whether the listing will convert to downloads, whether the idea will resonate with people to try, and whether any one-time users will keep the app on their devices and remember to use it. I don’t use the Shinkansen enough to estimate this well.&lt;/p&gt;

&lt;p&gt;Regardless, I’m glad the app exists now. I hope it saves at least a few people that little extra friction in an otherwise smooth Shinkansen journey.&lt;/p&gt;
</description>
        <pubDate>Wed, 24 Dec 2025 05:53:39 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/24/introducing-shinkansen-live-v1/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/24/introducing-shinkansen-live-v1/</guid>
        
        <category>ios</category>
        
        <category>app</category>
        
        <category>shinkansenlive</category>
        
        
      </item>
    
      <item>
        <title>You Are a Frameworks Engineer at Big Tech Corp</title>
        <description>&lt;p&gt;You are a Frameworks engineer at Big Tech Corp (BTCorp).&lt;/p&gt;

&lt;p&gt;You got the job out of university a few years ago and have mostly enjoyed it.&lt;/p&gt;

&lt;p&gt;You’ve gotten used to the pace. Yearly release cycles.&lt;/p&gt;

&lt;p&gt;You just finished your last release cycle working on Framework X, a new key feature in BTCorp’s lineup that was internally forecasted to sell lots of hardware.&lt;/p&gt;

&lt;p&gt;You’re proud of what was shipped, but know there’s still a lot of work to be done.&lt;/p&gt;

&lt;p&gt;You’ve just been reassigned to a new team though. In fact, all of your colleagues working on Framework X have been reassigned. Word is that Framework X is essentially soft deprecated and will never receive any new features or bug fixes. Word is that it was poorly received by tech bloggers and marketing is already starting to scrub it from any materials they can.&lt;/p&gt;

&lt;p&gt;You’ve been reassigned to Framework UI. Framework UI is a new UI framework for BTCorp’s hardware. It’s been a skunkworks project for a few years, but finally has the blessing of a VP. After all, Framework CoreUI, the UI framework that’s been around for decades, has an awful reputation outside the company for being too hard to use. BTCorp has been losing developer mindshare and it’s finally affecting the bottom line.&lt;/p&gt;

&lt;p&gt;You and all your colleagues are used to Framework CoreUI though. There’s plenty of knowledgeable individuals inside the company that have built or used Framework CoreUI; anyone inside the company can read Framework CoreUI’s source code; and there’s plenty of internal documentation at the source level.&lt;/p&gt;

&lt;p&gt;You’re happy to be on the Framework UI project. It seems like an interesting technical challenge. Supposedly there’s a lot of executive team members that are monitoring it closely.&lt;/p&gt;

&lt;p&gt;You could spin this into a coveted L5 promotion.&lt;/p&gt;

&lt;p&gt;You learn that no one on the skunkworks team (or BTCorp at large) has built software with Framework UI that has shipped to users outside the company yet, but that doesn’t bother you. BTCorp is well known for being secretive about new projects.&lt;/p&gt;

&lt;p&gt;You meet with your new team and get assigned &lt;em&gt;your&lt;/em&gt; function. You’ll be responsible for everything to do with this function and have a year to get it implemented, documented, tested, and successfully presented at the next World Wide Developers Meetup (WWDM).&lt;/p&gt;

&lt;p&gt;Your function is called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PaintColor&lt;/code&gt;. It takes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Path&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color&lt;/code&gt; instance. There’s another team working on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PaintColor&lt;/code&gt; function is responsible for drawing color on a path. Any color on any path.&lt;/p&gt;

&lt;p&gt;You work with your tech lead to understand the requirements. They tell you you should be calling through to Framework CoreUI under the hood. Most of Framework UI is actually just a wrapper over Framework CoreUI.&lt;/p&gt;

&lt;p&gt;You’re told the underlying goal is to abstract away a lot of the complexity of Framework CoreUI so third-party developers are less intimidated by BTCorp’s tech stack.&lt;/p&gt;

&lt;p&gt;You get to work and quickly realize that this isn’t going to be as easy as you thought!&lt;/p&gt;

&lt;p&gt;You’re not just calling through to the existing Framework CoreUI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PaintColor&lt;/code&gt; function. Framework UI is in BTCorp’s oldest programming language. And you learn there’s a new system in place in Framework UI that defers draw calls. There’s a new concurrency system being tested but it’s not ready yet.&lt;/p&gt;

&lt;p&gt;You dig some more and find that going down to the byte level there’s some very complicated mapping you need to do to convert colors. It’s no problem because you’re well versed in algorithms and data structures (you passed the coding test during your interview process at BTCorp after all).&lt;/p&gt;

&lt;p&gt;You spend the next couple months getting the implementation perfect. There’s tons of transforms and bit shifting. It’s annoying you’ve had to rewrite it from scratch a few times already (the concurrency system keeps changing), but it’s okay because this is &lt;em&gt;your&lt;/em&gt; function and you’re committed to getting it right.&lt;/p&gt;

&lt;p&gt;You’re satisfied with the implementation. In your automated testing, it displays all your test colors fine.&lt;/p&gt;

&lt;p&gt;You’ve got plenty of time before the World Wide Developers Meetup.&lt;/p&gt;

&lt;p&gt;You start documenting your function.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/// Paints a color on a path.
///
/// - Parameters:
///   - Path: a path.
///   - Color: a color.
function PaintColor(Path, Color)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;You check the existing documentation for other functions in Framework UI and yours is far and beyond the most detailed. You were originally thinking about going into more detail about the edge cases, but you figure there’s still so much about Framework UI that’s in flux that it’s best not to overcommit.&lt;/p&gt;

&lt;p&gt;You hand off your documentation to the documentation team to copyedit and give final approval. They’re already very busy and understaffed, so they tell you to check back in about a month.&lt;/p&gt;

&lt;p&gt;You start working on your 30 minute presentation for WWDM. You’re told to emphasize how easy it is to paint colors on paths using Framework UI.&lt;/p&gt;

&lt;p&gt;You put together the simplest example you can in your first code sample on slide 24: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PaintColor(Path.square, Color.default)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You see nothing on the screen. Hmm… what’s going on? you think.&lt;/p&gt;

&lt;p&gt;You start debugging. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.default&lt;/code&gt; is an alias for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt;, the default system color. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; doesn’t show up either.&lt;/p&gt;

&lt;p&gt;You try the other semantic colors: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.yellow&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.red&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.green&lt;/code&gt;. All of them display fine. It’s just &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; that’s not painting.&lt;/p&gt;

&lt;p&gt;Your function’s only purpose is to paint colors and there’s a color it won’t paint.&lt;/p&gt;

&lt;p&gt;Your earlier testing was only on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color&lt;/code&gt;s initialized from random hexadecimal strings. Semantic colors were only introduced recently by the team in charge of colors.&lt;/p&gt;

&lt;p&gt;You dig deeper and find the exact low-level byte-code representation of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; that was chosen by the team. You put that directly into your function and it’s broken.&lt;/p&gt;

&lt;p&gt;You dig even deeper and realize this goes far beyond your understanding of the system. As far as you can tell though, it’s only this one color value that maps incorrectly into the Framework CoreUI painting function.&lt;/p&gt;

&lt;p&gt;You start freaking out a little bit. You realize that you’re going to need some help from the experts in the language team, the concurrency team, and Framework CoreUI team to get this solved.&lt;/p&gt;

&lt;p&gt;You go talk to your manager. You explain what you’ve uncovered. Those teams are busy, your manager says. The WWDM presentation is the most important thing right now, your manager says, let’s focus on that.&lt;/p&gt;

&lt;p&gt;You go to lunch and meet with some friends on other teams. You explain the situation as a hypothetical and ask what they would do. No one inside BTCorp is going to use Framework UI, so who cares, they say.&lt;/p&gt;

&lt;p&gt;You decide to stop by to see the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color&lt;/code&gt; team on the way back from lunch. You ask them if they have authority to tweak the semantic color values and they say no, you’ll have to talk to the design team.&lt;/p&gt;

&lt;p&gt;You head over to the design team’s office, interrupting a designer at your level and they seem annoyed. You explain in too much detail the function you’re working on and the situation with that exact shade of blue. They don’t understand any of the terms you’re using and besides, all this seems like your fault.&lt;/p&gt;

&lt;p&gt;You ask the designer if they would please just shift the semantic blue color one bit in either direction. It would be imperceptible to users, you say with an unearned certainty that can’t hide your desperation. The design team will not relent. It has to be &lt;em&gt;that&lt;/em&gt; blue. This was decided months ago and is already used in all the promotional materials.&lt;/p&gt;

&lt;p&gt;You go back to the team in charge of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color&lt;/code&gt; implementation. You ask, could we add a new semantic color to the API called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.paintColorBlue&lt;/code&gt; that’s one bit off from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;blue&lt;/code&gt;? It will make it easier for all third-party developers to work around the bug during the beta period. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color&lt;/code&gt; team’s manager does not think this will be aesthetically pleasing in the API and may cause confusion. All affected third-party developers can just redefine the color themselves with the hexadecimal initializer, they say.&lt;/p&gt;

&lt;p&gt;You give up with workarounds for now, confident you’ll find time and resources to fix the bug before the final release that’s still a couple months away.&lt;/p&gt;

&lt;p&gt;You finish writing your WWDM presentation, using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.red&lt;/code&gt; to show off your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PaintColor&lt;/code&gt; function. At the end of the presentation, you add a quick aside that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.default&lt;/code&gt; are currently broken.&lt;/p&gt;

&lt;p&gt;You get a note back from the WWDM presentation review team that says they’ve cut the section about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PaintColor&lt;/code&gt; being broken. The policy is not to mention any known bugs or malfunctions, as it makes BTCorp look bad and undermines confidence in the new Framework UI framework.&lt;/p&gt;

&lt;p&gt;You ask the documentation team if you can still add a “known issue” to the Framework UI beta release notes. They respond that it’s too close to WWDM and they don’t have time. They haven’t reviewed your main documentation yet either, but making any change will move it to the back of the queue. And you’re planning on fixing the bug before the main release, so adding any note about it for just the beta period doesn’t make sense.&lt;/p&gt;

&lt;p&gt;You record your WWDM presentation.&lt;/p&gt;

&lt;p&gt;Your presentation is lauded as one of the best at WWDM. All third-party developers are really excited about Framework UI. From what everyone can see, it’s so much easier to paint colors on paths now. All the examples from the presentations, including yours, look like such an advancement over Framework CoreUI.&lt;/p&gt;

&lt;p&gt;You attend a meet-the-framework-engineers session at WWDM, where third-party developers line up for hours to ask you and your colleagues questions. You’re terrified someone’s going to call out your bug. Luckily, Framework UI has only been available for a few hours, so only one developer asks you about the bug. You tell them to try restarting their computer and to get back in line. By the time they get to the front, you’ve gone home.&lt;/p&gt;

&lt;p&gt;Your next few months in the beta period after WWDM are hectic. You’ve been pulled away to fix more important bugs in Framework UI. Management is panicking because it’s become increasing clear that Framework UI is not yet capable of being used for real software.&lt;/p&gt;

&lt;p&gt;You watch as Framework UI is released on time anyway. Long time third-party developers are angry they’ve invested time in learning it and trying to build apps with it before the beta period ends. But management still considers it a success, because the narrative among the tech press is positive and new third-party developers are onboarding at record pace. Veteran third-party developers are simply using Framework CoreUI as they always have, just like the developers inside BTCorp.&lt;/p&gt;

&lt;p&gt;Your performance review season begins, and at the meeting your manager doesn’t mention anything about the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; bug you spent months agonizing over being unable to fix. Your manager does, however, mention a peer review from the design team that states you were “hard to work with”. Regardless, you weren’t going to get a promotion anyway, but your manager now has something documented to justify it.&lt;/p&gt;

&lt;p&gt;Your colleagues called it: it’s been a few months since launch and no developer inside BTCorp has attempted to use Framework UI for production work. Most haven’t even read the Getting Started page that the documentation team worked so hard on.&lt;/p&gt;

&lt;p&gt;You decide to jump ship and quit BTCorp, using the outwardly successful launch of Framework UI to secure your next gig at Competing Tech Corp.&lt;/p&gt;

&lt;p&gt;You check the third-party developer message board on your last day at BTCorp. You’re curious to see if there’s any feedback about Framework UI as you haven’t really kept up with the launch.&lt;/p&gt;

&lt;p&gt;You scan the first page and see a message on the board with a title that mentions &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.default&lt;/code&gt; not working. The original poster tracked down the issue to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; and even mentioned the same workaround.&lt;/p&gt;

&lt;p&gt;You respond anonymously:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;`Color.blue` is not supported in `PaintColor` at this time. 

If you&apos;re interested in this functionality, please submit a report to the external bug tracker.
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;You hit “Send” and not a moment later, the original poster has already responded, exasperated that they’ve already spent hours chasing down the bug, asking why supporting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Color.blue&lt;/code&gt; would need to be considered as a separate feature, and why you couldn’t have added this to the documentation.&lt;/p&gt;

&lt;p&gt;You close your laptop and leave your badge at the reception desk on your way out.&lt;/p&gt;
</description>
        <pubDate>Fri, 19 Dec 2025 15:33:38 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/19/you-are-a-frameworks-engineer-at-big-tech-corp/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/19/you-are-a-frameworks-engineer-at-big-tech-corp/</guid>
        
        <category>commentary</category>
        
        <category>apple</category>
        
        
      </item>
    
      <item>
        <title>SwiftUI Group Still(?) Considered Harmful</title>
        <description>&lt;p&gt;A number of years ago, I internalized a SwiftUI axiom after getting burned on what appeared at first as a heisenbug.&lt;/p&gt;

&lt;p&gt;The axiom:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Never use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; (with only a few exceptions).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;TL;DR: I still think this is a useful axiom, although at some point over the last several iOS updates the behavior of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is &lt;em&gt;less&lt;/em&gt; harmful when applied naively. But there are still some reasons why it’s useful to treat it with caution.&lt;/p&gt;

&lt;h2 id=&quot;group-distributes-its-modifiers-amongst-its-subviews&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; distributes its modifiers amongst its subviews&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is documented as a wrapper &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;View&lt;/code&gt;. From the official docs (emphasis mine):&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Use a group to collect multiple views into a single instance, without affecting the layout of those views, like an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HStack&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VStack&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Section&lt;/code&gt; would. After creating a group, &lt;strong&gt;any modifier you apply to the group affects all of that group’s members&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Apple specifically calls out what “affects all of that group’s members” means in the next paragraph, with an accompanying code sample:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLoggedIn&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;WelcomeView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;LoginView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;navigationBarTitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Start&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;The modifier applies to all members of the group — and not to the group itself. For example, if you apply &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear(perform:)&lt;/code&gt; to the above group, it applies to all of the views produced by the if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isLoggedIn&lt;/code&gt; conditional, and it executes every time &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isLoggedIn&lt;/code&gt; changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This burned me in the past because I had a screen pattern like the (very simplified version) below:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLoading&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLoading&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;ProgressView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;DataLoadedView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// make network request&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// set `isLoading = false` in completion handler&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;’s documented behavior, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; modifier is essentially distributed across the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;’s views:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLoading&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;ProgressView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;DataLoadedView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;fetchData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The bug &lt;em&gt;was&lt;/em&gt; (at the time) that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetchData&lt;/code&gt; will be called twice: once when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProgressView&lt;/code&gt; appears, and once again when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DataLoadedView&lt;/code&gt; appears.&lt;/p&gt;

&lt;p&gt;Of course, there are many new modifiers and patterns since iOS 13 or 14 or whenever I was bitten by this (probably before the documentation was added). Regardless, after learning about this behavior it sort of makes sense. And so I learned my lesson that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; should not be used in this case.&lt;/p&gt;

&lt;h2 id=&quot;whats-changed-with-group&quot;&gt;What’s changed with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;I’d locked this knowledge away and hadn’t considered it in years. However, coding agents seem to &lt;em&gt;love&lt;/em&gt; to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; to write the exact buggy code pattern I just illustrated above. I was curious enough to investigate it again.&lt;/p&gt;

&lt;p&gt;Well it turns out that in iOS 26 and maybe even as far back as iOS 15, in most cases &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; no longer distributes its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt; calls amongst its subviews. Read on for the caveats.&lt;/p&gt;

&lt;p&gt;From my testing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onDisappear&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt;, and maybe other modifiers &lt;strong&gt;seem to have been special-cased by Apple&lt;/strong&gt; to work at the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;-level and &lt;strong&gt;not&lt;/strong&gt; be distributed to subviews like they used to. Note that this means the documentation for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; (that I quoted above) is now incorrect.&lt;/p&gt;

&lt;h3 id=&quot;onappear-and-task&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Consider the following &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;View&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;left&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trailing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;According to the docs, this code should loop between the left and right rectangles. However, as of iOS 26 (and as far back as I can test, iOS 15), it runs the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; modifier once and stays showing the right blue rectangle.&lt;/p&gt;

&lt;video src=&quot;/images/swiftui-group-onappear-demo.mp4&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/swiftui-group-onappear-demo-poster.png&quot; width=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;The console:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;left
group
right
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;screen-level-views&quot;&gt;Screen-level Views&lt;/h3&gt;

&lt;p&gt;I stumbled on this lonely bug report from June 2020:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://forums.swift.org/t/inconsistency-in-how-groups-onappear-and-ondisappear-are-called/37111&quot;&gt;Inconsistency in how Group’s onAppear and onDisappear are called - Using Swift - Swift Forums&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Something that I noticed today and didn’t expect it is that if a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is not the root view of the screen, its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; is called per each child, while for a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; that is the root, the method is called once, regardless of the number of its children.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Group is the root of the screen, onAppear is called once&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// non-root view, onAppear is called once per each child&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;yellow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onAppear2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onDisappear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onDisappear2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            
            &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;purple&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onAppear1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onDisappear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.:. onDisappear1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;/* Output:
.:. onAppear1
.:. onAppear2
.:. onAppear2
*/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;My guess is that this was reported during iOS 13, right before iOS 14 beta.&lt;/p&gt;

&lt;p&gt;I tested this code as well, and it turns out the OP’s example no longer prints &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear2&lt;/code&gt; twice on iOS 15 or iOS 26.&lt;/p&gt;

&lt;h3 id=&quot;list&quot;&gt;List&lt;/h3&gt;

&lt;p&gt;The only case I’ve found (so far) where the documented &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt; (and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt;) distributed-across-subviews behavior still exists is when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is within a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;List&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;left&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trailing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;video src=&quot;/images/swiftui-group-list-demo.mp4&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/swiftui-group-list-demo-poster.png&quot; width=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;The console:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;left
group
right
group
left
group
right
... (repeats forever)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;regular-modifiers-with-group&quot;&gt;Regular modifiers with Group&lt;/h3&gt;

&lt;p&gt;As a check that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; still distributes its modifiers in the general case, I created a simple custom modifier that prints on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;init&lt;/code&gt; and applied it to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;PrintModifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ViewModifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;modifier init&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ContentView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@State&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;red&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;left&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;kt&quot;&gt;Rectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;infinity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trailing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                        &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;modifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;PrintModifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onAppear&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;group&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;isLeft&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The console:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;modifier init
group
left
modifier init
right
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;As we can see, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PrintModifier&lt;/code&gt; is being applied to each subview independently as is documented. We’ve somewhat proven to ourselves that in the general case, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; still distributes modifiers.&lt;/p&gt;

&lt;h2 id=&quot;when-is-group-useful&quot;&gt;When is Group useful?&lt;/h2&gt;

&lt;p&gt;When is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; still the right choice?&lt;/p&gt;

&lt;p&gt;Honestly, not very often!&lt;/p&gt;

&lt;h3 id=&quot;distributing-a-lot-of-modifiers-across-sibling-views&quot;&gt;Distributing a lot of modifiers across sibling views&lt;/h3&gt;

&lt;p&gt;If you really really really need to apply one or more modifiers independently to a set of sibling views &lt;strong&gt;and&lt;/strong&gt; you need the sibling views to stay legible to their current parent container, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; is the right choice.&lt;/p&gt;

&lt;p&gt;In the below toy example with Form:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Form&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Subtitle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Description&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I would prefer simply duplicating the modifier manually:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Form&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Subtitle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Description&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;In my experience, the conditions that lead to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; being useful for this case are exceedingly rare. In a quick search of my current codebase, I have only a couple examples.&lt;/p&gt;

&lt;p&gt;This one just barely makes sense as it applies 4 modifiers to these 2 slightly different conditional subviews. Not quite large enough to justify separating out into named views; not quite small enough to duplicate the modifiers inline.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;routeOption&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;departure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;departureOnly&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;HStack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;spacing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;arrow.up.right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;imageScale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;small&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;bold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;train.side.middle.car&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;horizontal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;arrival&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;arrivalOnly&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;HStack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;spacing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;train.side.middle.car&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;systemName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;arrow.down.right&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;imageScale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;small&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;bold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;horizontal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;11&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;font&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;caption&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;foregroundStyle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;secondary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;vertical&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Material&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ultraThick&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RoundedRectangle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;cornerRadius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;overcoming-the-10-subview-limit-no-longer-applies&quot;&gt;&lt;del&gt;Overcoming the 10-subview limit&lt;/del&gt; (no longer applies)&lt;/h3&gt;

&lt;p&gt;Before Swift 5.9’s variadic generics, ViewBuilder could only handle 10 non-enumerated subviews. This example is still in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Group&lt;/code&gt; docs but no longer applies:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// No longer applies as of Swift 5.9&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;VStack&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Group&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;5&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;6&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;7&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;9&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;10&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;11&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
</description>
        <pubDate>Fri, 12 Dec 2025 10:37:39 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/12/swiftui-group-still-considered-harmful/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/12/swiftui-group-still-considered-harmful/</guid>
        
        <category>apple</category>
        
        <category>swiftui</category>
        
        <category>ios</category>
        
        
      </item>
    
      <item>
        <title>Comprehensible Later: A Read-it-later App for Language Learners</title>
        <description>&lt;p&gt;This post is a short retrospective on Comprehensible Later, my working-title for a read-it-later iOS app prototype I worked on last week. Although it’s currently in private beta on Test Flight, I want to share the motivation and technical challenges I ran into while working on it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_screens.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Share Extension presented from Safari, main app article list, and article detail screens&quot; title=&quot;Share Extension presented from Safari, main app article list, and article detail screens&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Share Extension presented from Safari, main app article list, and article detail screens&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;TLDR: Comprehensible Later is an iOS app for saving articles natively written in a language you’re learning, with automatic translation via LLM to a simpler version of your target language. The goal is to give you more interesting things to read at a level you can understand without needing to pause to look up every other word.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_japanese_comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Original level and simple level for a Japanese article&quot; title=&quot;Original level and simple level for a Japanese article&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original level and simple level for a Japanese article&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_english_comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Original level and simple level for an English article (from this blog)&quot; title=&quot;Original level and simple level for an English article (from this blog)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original level and simple level for an English article (from this blog)&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;what-is-comprehensible-input&quot;&gt;What is Comprehensible Input&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Input_hypothesis&quot;&gt;Comprehensible Input&lt;/a&gt; is part of a language acquisition framework first introduced by &lt;a href=&quot;https://www.sdkrashen.com/&quot;&gt;Dr. Stephen D. Krashen&lt;/a&gt;. The framework states that language is separately &lt;em&gt;acquired&lt;/em&gt; and &lt;em&gt;learned&lt;/em&gt;. &lt;em&gt;Acquisition&lt;/em&gt; happens by ensuring ample input (reading or listening) with the important caveat that the input is &lt;em&gt;comprehensible&lt;/em&gt; at the learner’s current level. &lt;em&gt;Learning&lt;/em&gt; happens through comprehensive study of rules and vocabulary. From this &lt;a href=&quot;https://www.dreaming.com/blog-posts/the-og-immersion-method&quot;&gt;summary&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;When we receive comprehensible input, the conditions are met for our brain to be able to use its natural ability to acquire language, without having to do anything else. There’s no need to study, review vocabulary, or practice anything. Watching and reading itself results in acquisition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In some senses, this method seems intuitive, not in the least since almost all children acquire language skills before they begin formal teaching. In a second-language context, &lt;a href=&quot;https://en.wikipedia.org/wiki/Graded_reader&quot;&gt;graded readers&lt;/a&gt; – books written for various non-native language levels – have existed for over a century. Wikipedia even has a &lt;a href=&quot;https://simple.wikipedia.org/&quot;&gt;simple English&lt;/a&gt; language variant for many common articles. I’ve occasionally used the modern &lt;a href=&quot;https://www.satorireader.com/&quot;&gt;Satori Reader&lt;/a&gt; service for Japanese graded texts.&lt;/p&gt;

&lt;p&gt;But I think the important part is recognizing exactly &lt;em&gt;how basic&lt;/em&gt; you need to make some input in order for it to be understandable, especially at the absolute-beginner level. In a since-removed introductory YouTube video from the creator, he shows a session of an instructor sitting with a zero-level beginner student, pointing at vivid images in a travel magazine and gesturing heavily while explaining the contents very slowly in the target language as the primary means of bootstrapping.&lt;/p&gt;

&lt;p&gt;Language is so multi-dimensional that it’s incredibly time consuming – both as a creator &lt;em&gt;and&lt;/em&gt; a consumer of materials – to get the exact level of material that is both comprehensible but challenging enough to increase your overall ability. Then add another dimension of &lt;em&gt;motivation&lt;/em&gt;: as a reader, how do you find materials with a subject matter that’s interesting to you and will keep you motivated to push through word-after-word, page-after-page, day-after-day?&lt;/p&gt;

&lt;p&gt;This led to a hypothesis:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Graded readers are usually close to the correct difficulty to facilitate learning, but do not have an audience wide enough to support a variety of interesting subject material.&lt;/li&gt;
  &lt;li&gt;Native materials cover an infinite range of interesting topics, but are infeasible to read until the latest stages of language acquisition.&lt;/li&gt;
  &lt;li&gt;One of the most commonly accepted use-cases for LLMs is text summary and translation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What if we used LLMs to translate any native article on-demand to the user’s exact target language level?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The barriers to this being feasible are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Can an LLM properly translate from e.g. Native Japanese to JLPT N4 level Japanese in a “natural” way – where it is simultaneously challenging, comprehensible, and accurate?&lt;/li&gt;
  &lt;li&gt;Can the translation happen fast enough to fit within a user’s desired language-learning workflow?&lt;/li&gt;
  &lt;li&gt;What additional resources are required to facilitate language learning? In-line dictionary lookup? An SRS system? Customized word lists?&lt;/li&gt;
  &lt;li&gt;What unique points are there to each target language that increase the interface complexity? For example, for Japanese learning, should we include furigana for all potentially unknown kanji?&lt;/li&gt;
  &lt;li&gt;Does it also make sense to allow translation from e.g. Native English to simple Japanese (if the target language is Japanese)?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;specification-for-the-prototype-app&quot;&gt;Specification for the prototype app&lt;/h2&gt;

&lt;p&gt;I’ve used 2025 frontier LLMs to write and translate simple versions of text before, so I’m confident it’s either possible now at a minimally acceptable translation quality or will be in the very near future.&lt;/p&gt;

&lt;p&gt;What I wasn’t confident about is whether it’s cost prohibitive or time prohibitive to use the highest quality reasoning models to do the translation.&lt;/p&gt;

&lt;p&gt;In retrospect, I should have spent at least a little more time doing bench testing on the API versions of various models on a wide array of sample articles. Instead, I took the less (more?) pragmatic route of jumping into the implementation for an app prototype that I could start using ASAP in context, as well as distribute to a few friends.&lt;/p&gt;

&lt;p&gt;My initial thought was that my main source of content would be blog posts and news articles I come across from my everyday feed scrolling. But I also felt I should support translating raw text too, like that from social media posts.&lt;/p&gt;

&lt;p&gt;I considered a Safari Extension to replace existing text on a webpage with the simplified translation, similar to how the built-in translation function in Safari works. But my gut-feeling was that this would be too limiting for language learning use cases. Even reading a text at a simpler level of a target language still takes enough time that it would be better to ensure the user doesn’t feel obligated to read everything at once. Additionally, this wouldn’t work for native text outside of Safari.&lt;/p&gt;

&lt;p&gt;My next thought was a Share Extension. Share Extensions are old iOS technology, but still highly used and useful. In a share extension I could display the translated article content in a dedicated modal and have full control over its presentation and layout.&lt;/p&gt;

&lt;p&gt;However, I also wanted to support the read-it-later use case. Personally, I stumble upon articles when doing feed scrolling sessions when I have a few minutes on the train but don’t necessarily have the time to read the whole article, even in English, at that time. I use Instapaper for read-it-later for English articles and I felt this would be a similarly useful use case to model my app after.&lt;/p&gt;

&lt;p&gt;With that in mind I got to work on the actual prototype with the following initial spec:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A native app that:
    &lt;ul&gt;
      &lt;li&gt;keeps a list of articles imported from URLs or as raw text.&lt;/li&gt;
      &lt;li&gt;has a detail view that shows both the original and translated versions of the text.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;A share extension that:
    &lt;ul&gt;
      &lt;li&gt;immediately processes the shared URL or text and displays it in the share modal.&lt;/li&gt;
      &lt;li&gt;allows the user to optionally save the translated text in the app for later.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note that as of iOS 18.4, there’s actually &lt;em&gt;another&lt;/em&gt; option for the interface: &lt;a href=&quot;https://developer.apple.com/documentation/TranslationUIProvider/Preparing-your-app-to-be-the-default-translation-app&quot;&gt;TranslationUIProviderExtension&lt;/a&gt;. iOS users can replace Apple’s Translation app with another translation app, meaning the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Translate&lt;/code&gt; menu tooltip can open a third-party app. I have mine set to &lt;a href=&quot;https://www.deepl.com/&quot;&gt;DeepL&lt;/a&gt;. Due to the limitations I’ll discuss later (namely, translation processing time), it doesn’t make sense yet to implement Comprehensible Later as a Translation Extension.&lt;/p&gt;

&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;

&lt;p&gt;I worked through several iterations of a detailed implementation spec with Claude and Codex then set them off to work getting the foundations of the app in place. This wasn’t exactly vibe coding because I specified technologies and packages to use up front and guided their output along the way. But I was still aiming to have the agents create the clay that I’d be molding in a distinct second phase of development.&lt;/p&gt;

&lt;h3 id=&quot;packages&quot;&gt;Packages&lt;/h3&gt;

&lt;p&gt;The key packages that would make this closer to a weekend prototype and not a months-long project were:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/Ryu0118/swift-readability&quot;&gt;swift-readability&lt;/a&gt; - wrapper for Firefox’s &lt;a href=&quot;https://github.com/mozilla/readability&quot;&gt;reader-view parsing library&lt;/a&gt; for stripping down a full page HTML to its essential content.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/mattt/AnyLanguageModel&quot;&gt;AnyLanguageModel&lt;/a&gt; - use any LLM API with Apple’s Foundation Models SDK interface.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui&quot;&gt;swift-markdown-ui&lt;/a&gt; - display the full Markdown spec in SwiftUI (note: I later replaced this).&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/steipete/Demark&quot;&gt;Demark&lt;/a&gt; - convert HTML-to-Markdown.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/JohnSundell/Ink&quot;&gt;Ink&lt;/a&gt; - convert Markdown-to-HTML.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/pointfreeco/sqlite-data&quot;&gt;sqlite-data&lt;/a&gt; - SQLite wrapper for local article storage and observable data layer for the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;data-flow&quot;&gt;Data flow&lt;/h3&gt;

&lt;p&gt;The initial data flow for articles imported via URL was the following:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input URL
    ↓ URLSession
HTML Data
    ↓ String(data:)
HTML String
    ↓ Readability
Clean HTML
    ↓ Demark
Markdown
    ↓ AnyLanguageModel
Translated MD
    ↓ swift-markdown-ui
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The initial flow for raw text:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input Text
    ↓ AnyLanguageModel
Translated MD
    ↓ swift-markdown-ui
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;However, due to limitations with the swift-markdown-ui package, the final version of the prototype uses this flow:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input URL
    ↓ URLSession
HTML Data
    ↓ String(data:)
HTML String
    ↓ Readability
Clean HTML
    ↓ Demark
Markdown
    ↓ AnyLanguageModel
Translated MD
    ↓ Ink
HTML
    ↓ WebView
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;With the bones of the app architecture and dependencies in place, I began testing and optimizing the data flow.&lt;/p&gt;

&lt;h3 id=&quot;readability&quot;&gt;Readability&lt;/h3&gt;

&lt;p&gt;I found a small bug in the Readability Swift wrapper where the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;baseURL&lt;/code&gt; parameter was inaccessible to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URL&lt;/code&gt;-based initializer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability().parse(url:options:)&lt;/code&gt;. This prevented relative image tags from getting properly resolved to a full address. For example, on my website image tags look like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/images/example.jpg&lt;/code&gt; and are resolved by my browser automatically to be either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://twocentstudios.com/images/example.jpg&lt;/code&gt; (the real server) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://localhost:4000/images/example.jpg&lt;/code&gt; (my local machine).&lt;/p&gt;

&lt;p&gt;Luckily, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;baseURL&lt;/code&gt; parameter was accessible in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability().parse(html:options:baseURL:)&lt;/code&gt; initializer. As a workaround I simply needed to fetch the page data myself with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URLSession&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;demark&quot;&gt;Demark&lt;/h3&gt;

&lt;p&gt;Demark has two different HTML-&amp;gt;Markdown parsing implementations: heavy-and-accurate or fast-and-inaccurate. Since the HTML is getting pre-processed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability&lt;/code&gt; in advance of being passed to Demark, I’m using the fast-and-inaccurate version that doesn’t load the full page in a headless &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WKWebView&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;anylanguagemodel&quot;&gt;AnyLanguageModel&lt;/h3&gt;

&lt;p&gt;As of iOS 26, Apple’s local Foundation model is slow, not-ubiquitously available on devices, and (arguably) barely functional for most use cases, especially mine. Within a few years I expect it may be useful. Similarly, my impression is that any other MLX-compatible models runnable on an iOS device are not yet accurate or fast enough for my use case.&lt;/p&gt;

&lt;p&gt;Therefore, I grabbed both an OpenAI and Gemini API key and wired them up to AnyLanguageModel for testing. I ran a few trials with the top-tier, mini, and nano variants and decided on defaulting to the mini variant as a compromise between speed, cost, and accuracy. Specifically, Gemini Flash 2.5 is the current default, but I suspect I could spend several weeks creating and running benchmarks across the dozens of closed and open models.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/mattt/AnyLanguageModel&quot;&gt;AnyLanguageModel&lt;/a&gt; made it easy to build a user settings-based model switcher with very little code adjustments required on my side. Technically, Gemini ships an &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/openai&quot;&gt;OpenAI-compatible endpoint&lt;/a&gt; so I could have kept even more of the same codepath. During debugging, I realized that AnyLanguageModel wasn’t passing through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;instructions&lt;/code&gt; parameter to OpenAI, so I submitted a &lt;a href=&quot;https://github.com/mattt/AnyLanguageModel/pull/20&quot;&gt;quick PR&lt;/a&gt; and Mattt had it merged and version bumped by the next day.&lt;/p&gt;

&lt;p&gt;In a later mini-sprint, I added a full settings screen that allows switching model provider, model, target language, target difficulty, adding custom translation instructions, and even fully rewriting the system prompt. Of course, I would never include all these settings in a production app, but it’s useful for my trusted beta testers to tinker if they so choose.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_settings.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The translation settings screen&quot; title=&quot;The translation settings screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The translation settings screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;By default, my (simple) system prompt is:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Faithfully translate the native-level input markdown text into the target language with the target difficulty level.&lt;/p&gt;

  &lt;p&gt;Be creative in transforming difficult words into simpler phrases that use vocabulary at the target difficulty level. Combine or split sentences when necessary, but try to preserve paragraph integrity.&lt;/p&gt;

  &lt;p&gt;The output format should be standard Markdown including all supported markdown formatting like image/video tags. Preserve all structure from the input (paragraphs, lists, headings, links, images, videos). DO NOT ADD COMMENTARY.&lt;/p&gt;

  &lt;p&gt;Target language: \(targetLanguage)&lt;br /&gt;
Target difficulty level: \(targetDifficulty)&lt;br /&gt;
Additional notes: \(additionalNotes)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ll discuss my impressions of the effectiveness of this prompt a little later on.&lt;/p&gt;

&lt;p&gt;Something I noticed almost immediately during testing was that requests were taking at minimum 30 seconds and sometimes over 60 seconds to complete. It didn’t really depend on model size either. I found the same performance characteristics for both OpenAI and Gemini APIs direct from first-party servers. I thought it might be the streaming API or perhaps some configuration in AnyLanguageModel I was not in control of, so I switched back to the single-request version. It didn’t help. I also began testing the same prompt and inputs from the API sandbox pages like &lt;a href=&quot;https://platform.openai.com/chat/edit&quot;&gt;OpenAI’s playground&lt;/a&gt; and &lt;a href=&quot;https://aistudio.google.com/u/1/prompts/new_chat&quot;&gt;Google’s AI Studio&lt;/a&gt; and saw basically the same results.&lt;/p&gt;

&lt;p&gt;Although the slow translation speed is a pretty substantial blocker, I felt like, at least temporarily, I could work around it in the UX by leaning into the read-it-later nature of the app. I added support for Apple’s &lt;a href=&quot;https://developer.apple.com/documentation/backgroundtasks&quot;&gt;Background Tasks&lt;/a&gt; API so there was a greater chance that articles added early in the day would be ready to read by the time the user opened the app.&lt;/p&gt;

&lt;h3 id=&quot;app-ui&quot;&gt;App UI&lt;/h3&gt;

&lt;p&gt;With the translation flow in place, I began shaping the app UI.&lt;/p&gt;

&lt;p&gt;The list of articles was simple enough. I held off on adding lots of important, but not urgent, contextual actions like archiving and deleting from the list view.&lt;/p&gt;

&lt;p&gt;I did add both “import from pasteboard” and “import from free text” buttons to the toolbar.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_list.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The article list view showing toolbar buttons for importing from pasteboard and free text&quot; title=&quot;The article list view showing toolbar buttons for importing from pasteboard and free text&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The article list view showing toolbar buttons for importing from pasteboard and free text&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_import_screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The import URL/text screen&quot; title=&quot;The import URL/text screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The import URL/text screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I spent more time on the article detail view. Initially, it displayed the title, import state, and translated article. My focus for adding actions was to facilitate debugging primarily for myself and secondarily for my beta testers. This meant buttons for copying the original article text, copying translated article text, deleting an article, opening the original link, and retrying the translation (with different settings).&lt;/p&gt;

&lt;p&gt;After some initial usage, I realized I wanted to see the original text and the translated text side-by-side so that I could compare the language usage by sentence and paragraph.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_detail.jpg&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Article detail view with options to display original and translated text&quot; title=&quot;Article detail view with options to display original and translated text&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Article detail view with options to display original and translated text&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_actions.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Supported actions for article detail&quot; title=&quot;Supported actions for article detail&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Supported actions for article detail&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_debug_info.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Debug info viewer&quot; title=&quot;Debug info viewer&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Debug info viewer&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, the most time-consuming and impactful change was the markdown display system. This was a tough decision, but I think ultimately necessary for the first version.&lt;/p&gt;

&lt;p&gt;Originally, I was planning to use &lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui&quot;&gt;swift-markdown-ui&lt;/a&gt; to display the translated markdown text in SwiftUI. This implementation was basically plug-and-play, rendered exactly as I wanted, supported images out of the box, and was performant. However, the &lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui/issues/264&quot;&gt;one fundamental and unsolvable issue&lt;/a&gt; is that &lt;strong&gt;SwiftUI Text only supports paragraph level copy support and does not support character-level or word-level selection&lt;/strong&gt;. For language learning, I absolutely need the ability to select a word and use the context menu tooltip action “Look Up” or “Translate” or “Copy” buttons. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swift-markdown-ui&lt;/code&gt; would not be able to support this and I needed to research other solutions.&lt;/p&gt;

&lt;p&gt;I spent nearly a full day researching and experimenting with other Markdown solutions. My second preference was to convert Markdown to AttributedString either &lt;a href=&quot;https://developer.apple.com/documentation/foundation/instantiating-attributed-strings-with-markdown-syntax&quot;&gt;natively&lt;/a&gt; or &lt;a href=&quot;https://github.com/madebywindmill/MarkdownToAttributedString&quot;&gt;with a package&lt;/a&gt;, then display the AttributedString in a &lt;a href=&quot;https://github.com/kevinhermawan/SelectableText&quot;&gt;SwiftUI-wrapped UITextView&lt;/a&gt; with selection enabled but editing disabled. However, both the native and package versions of AttributedString initialization failed at properly respecting whitespace, newlines, and supporting images. My estimation was that it’d take significantly more time for me to grok the full Markdown spec, all the underlying packages, and then implement the required patches than I was willing to spend for a prototype.&lt;/p&gt;

&lt;p&gt;Therefore, I pivoted to using a browser-based target view instead. iOS 26 was blessed with &lt;a href=&quot;https://developer.apple.com/documentation/webkit/webview-swift.struct&quot;&gt;WebView&lt;/a&gt;, a modern SwiftUI-native implementation of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIWebView&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WKWebView&lt;/code&gt; UIKit views before it. With a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WebView&lt;/code&gt; as the new target, I used &lt;a href=&quot;https://github.com/JohnSundell/Ink&quot;&gt;Ink&lt;/a&gt; to convert the LLM output Markdown back to HTML, added a barebones stylesheet, and loaded these contents. I don’t love using a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WebView&lt;/code&gt; for this use case since it’s comparatively heavy, has plenty of rendering quirks (like occasional white background flashes), and requires a full screen layout. But at the moment it’s the least-worst option.&lt;/p&gt;

&lt;h3 id=&quot;share-extension-and-action-extension&quot;&gt;Share Extension and Action Extension&lt;/h3&gt;

&lt;p&gt;Unfortunately, the slow translation speed meant some of the complexity of creating a fully-featured Share Extension was in vain; it didn’t make sense for the user to wait 30-60 seconds for the share extension to load a preview of the article content like I’d originally planned.&lt;/p&gt;

&lt;p&gt;My initial vision was to load a one-page preview of the translation as quickly as possible. Then, I’d allow the user to tap a button to continue viewing the full translation in line. Or at any time they could tap a button to save the article URL (or raw text) to the main app to read later. I was planning on having an “open in app” button too, but as far as I can tell it’s not supported to open an app directly from a Share Extension.&lt;/p&gt;

&lt;p&gt;I kept the full functionality of the share extension intact in case I can solve the translation speed issue in the future. But as another workaround, I added an Action Extension. An Action Extension appears in the bottom section of the system share sheet. Like a Share Extension it can also present custom UI, however since I already have a Share Extension I made my Action Extension have no UI and immediately save the URL to the app.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_share_sheet.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&quot; title=&quot;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_share_extension.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Share Extension with translation complete&quot; title=&quot;Share Extension with translation complete&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Share Extension with translation complete&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;import-flow&quot;&gt;Import flow&lt;/h3&gt;

&lt;p&gt;App Extensions can share data on device with the main app using an &lt;a href=&quot;https://developer.apple.com/documentation/Xcode/configuring-app-groups&quot;&gt;App Group&lt;/a&gt;. When the user indicates they want to add the URL or raw text to the app, the Extension serializes an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; model to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json&lt;/code&gt; and writes a new file to the App Group. The main app monitors the shared App Group directory for new files. When it detects a new file, it adds the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; to the app’s SQLite database. If the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; already finished translation, it will include the translated markdown and no further processing is necessary. Otherwise, it will be queued for processing.&lt;/p&gt;

&lt;p&gt;I chose not to share the SQLite database between the main app and the extensions because, since the app and extensions are separate processes, there are &lt;a href=&quot;https://swiftpackageindex.com/groue/GRDB.swift/v7.8.0/documentation/grdb/databasesharing&quot;&gt;myriad issues&lt;/a&gt; with using SQLite in this way. Since data sharing is one way (from extension to app) there’s no need to introduce that complexity.&lt;/p&gt;

&lt;p&gt;Adding articles from the main app instance skips the file encoding/decoding step and simply writes a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; to the database.&lt;/p&gt;

&lt;p&gt;The processing code is admittedly a bit fragile, but in testing has worked well enough that I haven’t felt an immediate need to rewrite it. It uses an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enum Status&lt;/code&gt; stored alongside each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; in the database in order to manage the translation queue, including failures. &lt;a href=&quot;https://github.com/pointfreeco/sqlite-data&quot;&gt;SQLiteData&lt;/a&gt; supports observation, so both the article list view and the article detail view are always up to date on an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt;’s status.&lt;/p&gt;

&lt;h3 id=&quot;localization&quot;&gt;Localization&lt;/h3&gt;

&lt;p&gt;Localizing a prototype would be something I’d never consider doing before the advent of coding agents. The actual act of translation between a base language and another language is insignificant compared to the amount of additional tooling and operational complexity of introducing localization keys, adding comments, handling interpolation, handling pluralization rules, handling error messages and other strings generated deep in business logic, and handling the indirection involved in looking up the values for the keys. The new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; file’s autogeneration definitely helps ease the burden. But it’s at least an order of magnitude more work in my opinion.&lt;/p&gt;

&lt;p&gt;All that said, coding agents can automate enough of this work that I added full localization support for Japanese for one of my beta testers who wanted to try the app for converting English to simple English. I’m still cognizant of the ongoing support complexity full localization adds to a prototype, but for now it’s not a decision I regret.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_japanese_localization.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Various screens with Japanese localization&quot; title=&quot;Various screens with Japanese localization&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Various screens with Japanese localization&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;impressions-so-far&quot;&gt;Impressions so far&lt;/h3&gt;

&lt;p&gt;What I’ve learned so far is that the prompt needs to be more customized to each target language and should probably go as far as including an allow-list of words to use, especially for the most basic target difficulties.&lt;/p&gt;

&lt;p&gt;I’ve found the models have a hard time with native Japanese news articles. Something about the language is just so dense that my first prompt attempt does not push the model to simplify enough.&lt;/p&gt;

&lt;p&gt;Similar to what I’ve found with even commercial apps like Instapaper, a large percentage of sites now have enough paywall or otherwise reader-hostile javascript that it’s not enough to fetch a simple URL directly from the source. I’m not ready to handle the endless, unforgiving work of handling all the edge cases of the open web, so URL fetching is going to be best effort for the foreseeable future.&lt;/p&gt;

&lt;p&gt;The Readability library itself is not perfect at parsing out text from pages that aren’t obviously written as “articles”. This isn’t all that different from the built-in Safari reader mode which isn’t universally supported across the entire web.&lt;/p&gt;

&lt;p&gt;Seeing some of my blog posts in super-simple English was really fun. One of my ongoing goals is to write simpler without giving up my voice, so seeing how an LLM breaks up my sentences and phrases and clauses is enlightening (of course, not at all related to the use case the prototype was built for).&lt;/p&gt;

&lt;p&gt;For Japanese, there’s some unpredictability on how the LLM deals with kanji. Usually it includes kanji as is, but sometimes it will add the reading in parentheses directly after for literally every word. For example, “果物（くだもの）を食べる（たべる）”. Native ruby/furigana support would be ideal, and possibly easier using HTML than &lt;a href=&quot;https://github.com/ApolloZhu/RubyAttribute&quot;&gt;AttributedString&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next?&lt;/h2&gt;

&lt;p&gt;Comprehensible Later is on Test Flight in private beta with myself and a few friends. I’m planning on collecting feedback and evaluating the app’s potential for wider release. It could take another generation or two of LLM. It could take as long as waiting for local models to improve. Or the entire concept could be flawed. I’m not sure yet. But that’s what the prototype is for.&lt;/p&gt;

&lt;p&gt;Regardless of the result, it was of course a good learning experience to see what it’s like to build a read-it-later service for iOS in 2025.&lt;/p&gt;
</description>
        <pubDate>Sat, 15 Nov 2025 05:57:31 -0600</pubDate>
        <link>https://twocentstudios.com/2025/11/15/comprehensible-later-read-it-later-for-language-learners/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/11/15/comprehensible-later-read-it-later-for-language-learners/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>comprehensiblelater</category>
        
        <category>app</category>
        
        
      </item>
    
      <item>
        <title>An E-Ink Desk Companion With M5Paper</title>
        <description>&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/m5paper-desk-unit.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The M5Paper on my desk displaying my daily TODO list&quot; title=&quot;The M5Paper on my desk displaying my daily TODO list&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The M5Paper on my desk displaying my daily TODO list&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I’ve had an &lt;a href=&quot;https://shop.m5stack.com/products/m5paper-esp32-development-kit-v1-1-960x540-4-7-eink-display-235-ppi&quot;&gt;M5Paper&lt;/a&gt; since my days in the R&amp;amp;D Department at Cookpad that I’ve never known what to do with. My friend Dave has been doing a lot of microcontroller projects in his spare time, and it inspired me to get out the M5Paper again.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/m5paper-marketing.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;M5Paper ESP32 development kit with E-Ink display marketing image&quot; title=&quot;M5Paper ESP32 development kit with E-Ink display marketing image&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;M5Paper ESP32 development kit with E-Ink display marketing image&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;M5Paper is an all-in-one development kit device that wraps an ESP32 microcontroller, a rechargeable battery, some environment sensors, and sticks on a 4.7” capacitive-touch E-Ink display. I bought it for about ¥8000 a couple years ago, but the &lt;a href=&quot;https://shop.m5stack.com/products/m5papers3-esp32s3-development-kit&quot;&gt;improved version S3&lt;/a&gt; goes for $59 USD at the time of this writing.&lt;/p&gt;

&lt;p&gt;My new idea was to have the M5Paper on my desk mirroring my current TODO list for the day.&lt;/p&gt;

&lt;h2 id=&quot;feasibility-research&quot;&gt;Feasibility research&lt;/h2&gt;

&lt;p&gt;I keep a daily markdown file with my TODOs at the top and then notes about the day’s work below it. I’ve been doing this for the last 2 years. Previously I’d use one markdown file for an entire year and use it as a rolling TODO list and scratch pad (this actually worked surprisingly well).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/m5paper-obsidian-daily-notes.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Daily notes in Obsidian with TODO items at the top&quot; title=&quot;Daily notes in Obsidian with TODO items at the top&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Daily notes in Obsidian with TODO items at the top&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I’ve found that my daily TODO list often falls off my radar as soon as the Obsidian window gets buried behind all the others. I end up leaving TODOs unchecked or, even worse, completely forget to do a task. &lt;strong&gt;I realized the E-Ink display of the M5Paper was one way to keep my TODO list in my literal peripheral vision without requiring any additional TODO list management&lt;/strong&gt;, including manual duplication or otherwise altering my current workflow.&lt;/p&gt;

&lt;p&gt;The Obsidian vault I use for my daily notes and project notes is sourced from a folder synced with Dropbox. Therefore, I realized I could have the M5Paper run the following:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Wake up and connect to WiFi&lt;/li&gt;
  &lt;li&gt;Calculate the current date&lt;/li&gt;
  &lt;li&gt;Use the Dropbox API to fetch a file called 2025-10-10.md within a hardcoded folder path (i.e. the current date)&lt;/li&gt;
  &lt;li&gt;Display the contents of the file on the E-Ink display&lt;/li&gt;
  &lt;li&gt;Go to sleep for some amount of time (an hour?) to conserve battery&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/m5paper-workflow-illustration.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Workflow diagram showing the M5Paper fetch and display cycle&quot; title=&quot;Workflow diagram showing the M5Paper fetch and display cycle&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Workflow diagram showing the M5Paper fetch and display cycle&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I had Claude Code and Codex help me along the prototyping journey.&lt;/p&gt;

&lt;h2 id=&quot;first-prototype&quot;&gt;First prototype&lt;/h2&gt;

&lt;p&gt;The first big decision was what environment to use for programming the ESP32. There’s the beginner-friendly &lt;a href=&quot;https://docs.m5stack.com/en/uiflow2/uiflow_web&quot;&gt;UiFlow2&lt;/a&gt; web IDE, the &lt;a href=&quot;https://docs.m5stack.com/en/arduino/arduino_ide&quot;&gt;Arduino IDE&lt;/a&gt;, the &lt;a href=&quot;https://platformio.org/&quot;&gt;PlatformIO&lt;/a&gt; plugin for VSCode, and then the more command line heavy route.&lt;/p&gt;

&lt;p&gt;After flashing the firmware with UiFlow2 (this required downloading an out-of-date macOS app), I decided to use MicroPython with command line tools. I’ve used PlatformIO and Arduino IDE before but I wanted to see if MicroPython was more ergonomic than the embedded C++ variant.&lt;/p&gt;

&lt;p&gt;The command line tools I ended up using were &lt;a href=&quot;https://docs.espressif.com/projects/esptool/en/latest/&quot;&gt;esptool&lt;/a&gt; and &lt;a href=&quot;https://docs.micropython.org/en/latest/reference/mpremote.html&quot;&gt;mpremote&lt;/a&gt;. Another reason I picked these was so my coding agents could be more helpful in being part of the development loop, although maybe the models have more training data for Arduino code?&lt;/p&gt;

&lt;p&gt;The Dropbox part was a big unknown for me. I was banking on being able to fetch from my existing folder hierarchy with the Dropbox API. If it turned out to be impossible I probably would have given up since I’m not willing to change my entire personal knowledge-base setup.&lt;/p&gt;

&lt;p&gt;Luckily, it was really easy to create a Dropbox App that worked only for my account. I could even generate a hardcoded API token with the right permissions that lasted 4 hours for testing. With that in place, Claude Code generated the first attempt at the code needed to get something from Dropbox onto the screen.&lt;/p&gt;

&lt;p&gt;The next step was understanding how deployment to device was supposed to work and then later automating it. This was especially error-prone because I was intentionally putting the device into deep sleep, which would cause it to become unresponsive to the base commands. In the end, I have a Makefile with a deploy command that looks like this:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-Makefile&quot;&gt;DEVICE_PORT = /dev/cu.usbserial-0214257D
ESPTOOL = python3 -m esptool --chip esp32 --port $(DEVICE_PORT) read_mac
MPREMOTE = python3 -m mpremote connect $(DEVICE_PORT)

.PHONY: deploy test list clean help reset rtc-check rtc-sync rtc-init repl-mode

deploy: ## Deploy main.py, secrets.py, and bm8563_rtc.py to M5Paper (full deployment)
	@echo &quot;🔄 Deploying to M5Paper...&quot;
	@echo &quot;⚡ Resetting device...&quot;
	@$(ESPTOOL) &amp;gt; /dev/null 2&amp;gt;&amp;amp;1
	@echo &quot;⏳ Waiting for boot...&quot;
	@sleep 4
	@echo &quot;📁 Copying files...&quot;
	@$(MPREMOTE) cp main.py :main.py &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; echo &quot;  ✓ main.py copied&quot; || echo &quot;  ⚠️ main.py failed&quot;
	@if [ -f secrets.py ]; then $(MPREMOTE) cp secrets.py :secrets.py &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; echo &quot;  ✓ secrets.py copied&quot; || echo &quot;  ⚠️ secrets.py failed&quot;; fi
	@if [ -f bm8563_rtc.py ]; then $(MPREMOTE) cp bm8563_rtc.py :bm8563_rtc.py &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; echo &quot;  ✓ bm8563_rtc.py copied&quot; || echo &quot;  ⚠️ bm8563_rtc.py failed&quot;; fi
	@echo &quot;🔄 Final reset...&quot;
	@$(ESPTOOL) &amp;gt; /dev/null 2&amp;gt;&amp;amp;1
	@echo &quot;✅ Deployment complete!&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This essentially just copies over the Python files from my Mac to the M5Paper, but with a few extra steps to make sure the device is ready to be written to. Hard-won knowledge, but after getting this in place, the development loop is about as fast as it can be.&lt;/p&gt;

&lt;p&gt;Putting the device in deep sleep blocks the REPL from connecting normally. Therefore, I also have a Makefile command for temporarily replacing the deployed contents with a dummy file.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-Makefile&quot;&gt;repl-mode: ## Replace main.py with a stub so the device boots straight to the REPL
	@echo &quot;🛠  Entering REPL mode...&quot;
	@echo &quot;⚡ Resetting device...&quot;
	@$(ESPTOOL) &amp;gt; /dev/null 2&amp;gt;&amp;amp;1
	@echo &quot;⏳ Waiting for boot...&quot;
	@sleep 4
	@echo &quot;🗑  Installing REPL stub...&quot;
	@$(MPREMOTE) cp repl_stub.py :main.py &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; echo &quot;  ✓ stub copied&quot; || echo &quot;  ⚠️ stub copy failed&quot;
	@echo &quot;🔄 Final reset...&quot;
	@$(ESPTOOL) &amp;gt; /dev/null 2&amp;gt;&amp;amp;1
	@echo &quot;✅ Device will now stay idle for REPL access.&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;going-beyond-the-prototype&quot;&gt;Going beyond the prototype&lt;/h2&gt;

&lt;p&gt;I had my first working prototype up and running after a few hours which was great. However, there were some lingering issues the next day:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The dev-use-only Dropbox API token I had used expired after 4 hours. I needed to use the full OAuth flow to get a refreshable token.&lt;/li&gt;
  &lt;li&gt;The real time clock (RTC) was often getting confused after wake-ups or power events and either returning to the unix epoch or defaulting to an incorrect hour during the correct day.&lt;/li&gt;
  &lt;li&gt;Japanese characters showed up as rectangular boxes.&lt;/li&gt;
  &lt;li&gt;I wanted a way to refresh the contents manually by pressing the side button.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;dropbox-api-token&quot;&gt;Dropbox API token&lt;/h3&gt;

&lt;p&gt;The Dropbox API token issue turned out to be the easiest solve of the above problems. Claude Code wrote me a quick script I could run on my Mac that used my Dropbox app secret credentials to return a refreshable token. I added that token to the secrets.py file on the M5Paper and now I had a long-term solution.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/m5paper-dropbox-app-api.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Dropbox app settings showing API token configuration&quot; title=&quot;Dropbox app settings showing API token configuration&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Dropbox app settings showing API token configuration&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;clock-issues&quot;&gt;Clock issues&lt;/h3&gt;

&lt;p&gt;As usual, anything with clocks or calendars is the hardest problem. 1. My lack of knowledge about the M5Paper itself combined with 2. the difficulty of isolating a test environment that allowed me to observe the device without interrupting it – prolonged the debugging experience.&lt;/p&gt;

&lt;p&gt;Through a lot of agent churn, I finally learned that the M5Paper has two clocks: ESP’s on-chip RTC and a dedicated chip external RTC connected to the lithium battery. Setting the external RTC with a one-off script during dev time would save a lot of hassle and complexity in the main loop.&lt;/p&gt;

&lt;h3 id=&quot;japanese-characters&quot;&gt;Japanese characters&lt;/h3&gt;

&lt;p&gt;I sometimes leave notes in Japanese. I noticed these characters appeared on the display as unrenderable unicode blocks like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;▯▯▯&lt;/code&gt;. Leaving them this way was not a deal breaker, but through a quick exploration of the M5 API, I found there was already a pre-loaded Japanese font I could use that solved the problem. The only down side is that the granularity of font sizes with the Japanese font makes it so I have to choose between a-little-too-small and a-little-too-large (I chose large).&lt;/p&gt;

&lt;h3 id=&quot;contents-refresh-and-power-management&quot;&gt;Contents refresh and power management&lt;/h3&gt;

&lt;p&gt;I was originally hoping I could use the M5Paper for long stretches on battery power. Unfortunately, I think its age has left the battery in a vestigial state. Plus, I have a new charging port available for it, which makes keeping it plugged in all the time no longer an issue.&lt;/p&gt;

&lt;p&gt;I’ve therefore set the refresh logic going forward as:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;On battery power: refresh once an hour.&lt;/li&gt;
  &lt;li&gt;On USB power: refresh once every 5 minutes.&lt;/li&gt;
  &lt;li&gt;Also refresh on side button tap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On redraws, the whole screen flickers black and white, then draws the text (quickly) line by line. This would be a little distracting to see in my field of view every 5 minutes, especially since the TODO list does not change that often (and I am the only one changing it). Therefore, I also added some diffing logic. Although the list is refreshed every 5 minutes, it will only redraw if the contents on screen have changed.&lt;/p&gt;

&lt;p&gt;The M5Paper does have a capacitive touch screen, but in my preliminary investigations, the deep sleep interrupt does not work the same way as it does for the hardware button. Since I’ve recently changed the refresh logic to account for unlimited USB power, I could go further and keep the ESP32 awake, add scrolling, and add other capacitive touch buttons to the display. But for now I’m happy with the light feature set.&lt;/p&gt;

&lt;p&gt;The final small task (mostly for ongoing debugging) was to add a battery level indicator to the bottom of the screen as a screen-width progress bar. I’m only using the battery level indicator to get a feel for how the internal battery handles daily usage going forward. This knowledge will inform future decisions about product direction (heavy air quotes on “product” since this is just for me).&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;If you’ve never done embedded systems development before, hopefully I’ve shown that building personal-use hardware is within reach for most software developers. There’s an infinite web of tangential devices and use cases out there (looking at you, Raspberry Pi 4 in a box in my closet). Hopefully this has given you, the reader, some ideas for fun weekend projects whose results you can enjoy every day.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/m5paper-final-setup.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The completed M5Paper desk companion in action&quot; title=&quot;The completed M5Paper desk companion in action&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The completed M5Paper desk companion in action&lt;/div&gt;&lt;/div&gt;
</description>
        <pubDate>Fri, 10 Oct 2025 13:39:28 -0500</pubDate>
        <link>https://twocentstudios.com/2025/10/10/an-e-ink-desk-companion-with-m5paper/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/10/10/an-e-ink-desk-companion-with-m5paper/</guid>
        
        <category>hardware</category>
        
        <category>micropython</category>
        
        <category>claudecode</category>
        
        <category>m5paper</category>
        
        <category>esp32</category>
        
        
      </item>
    
      <item>
        <title>Let&apos;s Write a Train Tracking Algorithm</title>
        <description>&lt;p&gt;I delivered a 20-minute presentation on September 20th at &lt;a href=&quot;https://iosdc.jp/2025/&quot;&gt;iOSDC Japan 2025&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you prefer video:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Japanese (conference): &lt;a href=&quot;https://www.youtube.com/watch?v=CdzUxJom3Ps&quot;&gt;YouTube&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;English (post-conference recording): &lt;a href=&quot;https://youtu.be/xBQlipN0pMg&quot;&gt;YouTube&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other materials:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/twocentstudios/train-tracker-talk&quot;&gt;GitHub: train-tracker-talk&lt;/a&gt; - Open source code and presentation materials&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/09/21/i-presented-at-iosdc-2025/&quot;&gt;Blog: I Presented At iOSDC 2025&lt;/a&gt; - More about the conference and presentation context&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id6745218674&quot;&gt;App Store: Eki Live&lt;/a&gt; - The app discussed in the presenation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is a deconstructed version of the talk with the slide images above and my speaker notes in English below.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-01.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Lately I’ve been working on an app called &lt;a href=&quot;https://twocentstudios.com/2025/06/03/eki-live-announcement/&quot;&gt;Eki Live&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;Today I’m going to talk about a part of that app.&lt;/li&gt;
  &lt;li&gt;So what do I mean by train tracking algorithm?&lt;/li&gt;
  &lt;li&gt;Well, when riding a train, it’s useful to know the upcoming station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-02.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;On the train, we can see the train information display or listen for announcements.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-03.mp4&quot; autoplay=&quot;&quot; controls=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;But would it also be useful to see this information in your Dynamic Island?&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-04.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In my talk, we’ll first review the data prerequisites we’ll need for the algorithm.&lt;/li&gt;
  &lt;li&gt;Then, we’ll write each part of the algorithm, improving it step-by-step.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-05.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We need two types of data for the train tracking algorithm:&lt;/li&gt;
  &lt;li&gt;Static data that describes the railway system of greater Tokyo.&lt;/li&gt;
  &lt;li&gt;And Live GPS data from the iPhone user.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-06.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Railways are ordered groups of Stations.&lt;/li&gt;
  &lt;li&gt;In this example, we can see that the Minatomirai Line is made up of 6 stations.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-07.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Trains travel in both Directions on a Railway.&lt;/li&gt;
  &lt;li&gt;Coordinates make up the path of a Railway’s physical tracks.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-08.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This map shows the Railway data we’ll be using.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-09.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We collect live GPS data from an iPhone using the Core Location framework.&lt;/li&gt;
  &lt;li&gt;We store the data in a local SQLite database.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-10.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; has all data from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Latitude, longitude, speed, course, accuracy, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-11.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A Session is an ordered list of Locations.&lt;/li&gt;
  &lt;li&gt;A Session represents a possible journey.&lt;/li&gt;
  &lt;li&gt;Green is for fast and red is for stopped.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-12.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I created a macOS app to visualize the raw data.&lt;/li&gt;
  &lt;li&gt;In the left sidebar there is a list of Sessions.&lt;/li&gt;
  &lt;li&gt;In the bottom panel there is a list of ordered Locations for a Session.&lt;/li&gt;
  &lt;li&gt;Clicking on a Location shows its position and course on the map.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-13.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Our goal is to write an algorithm that determines 3 types of information:&lt;/li&gt;
  &lt;li&gt;The Railway, the direction of the train, and the next Station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-14.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Here is a brief overview of the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-15.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app channels &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; values to the algorithm.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-16.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm reads the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; and gathers information from its memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-17.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm updates its understanding of the device’s location in the world.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-18.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm calculates a new result set of railway, direction, and station phase.&lt;/li&gt;
  &lt;li&gt;The result is used to update the app UI and Live Activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-19.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s start by considering a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;I captured this Location while riding the Tokyu Toyoko Line close to Tsunashima Station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-20.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Can we determine the Railway from just this Location?&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-21.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We &lt;em&gt;do&lt;/em&gt; have coordinates that outline the railway…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-22.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;First, we find the closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; for each Railway.&lt;/li&gt;
  &lt;li&gt;Then, we order the Railways by which &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; is nearest.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-23.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Here are our results.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-24.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; is from the Toyoko Line at only 12 meters away.&lt;/li&gt;
  &lt;li&gt;The next closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; is from the Shin-Yokohama Line at 177 meters away.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for &lt;em&gt;this&lt;/em&gt; case.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-26.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider another &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;This &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; was also captured on the Toyoko Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-27.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But in this section of the railway track, the Toyoko Line and Meguro Line run parallel.&lt;/li&gt;
  &lt;li&gt;It’s not possible to determine whether the correct line is Toyoko or Meguro from just this one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-28.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm needs to use all &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s from the journey.&lt;/li&gt;
  &lt;li&gt;The example journey follows the Toyoko Line for longer than the Meguro Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-29.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;First, we convert the distance between the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; and the nearest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RailwayCoordinate&lt;/code&gt; to a score.&lt;/li&gt;
  &lt;li&gt;The score is high if close and exponentially lower when far.&lt;/li&gt;
  &lt;li&gt;Then, we add the scores over time.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-30.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The score from Nakameguro to Hiyoshi is now higher for the Toyoko Line than the Meguro Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for this case.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-32.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider a third &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;This &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; was captured on the Keihin-Tohoku Line which runs the east corridor of Tokyo.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-33.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Several lines run parallel in this corridor.&lt;/li&gt;
  &lt;li&gt;The Tokaido Line follows the same track as the Keihin-Tohoku Line&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-34.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But the Tokaido Line skips many stations.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-35.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If we only compare railway coordinate proximity scores, the scores will be the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-36.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s add a small penalty to the score if a station is passed.&lt;/li&gt;
  &lt;li&gt;If a station is passed, that indicates the iPhone may be on a parallel express railway.&lt;/li&gt;
  &lt;li&gt;Let’s also add a small penalty to the score if a train stops between stations.&lt;/li&gt;
  &lt;li&gt;If a train stops between stations, that indicates the iPhone may be on a parallel local railway.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-37.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Using this algorithm, the Keihin-Tohoku score is now slightly larger than the Tokaido score.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-38.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider two example trips to better understand penalties.&lt;/li&gt;
  &lt;li&gt;For an example trip 1 that starts at Tokyo station…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-39.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The train stops at the second Keihin-Tohoku station.&lt;/li&gt;
  &lt;li&gt;The Tokaido score receives a penalty since the stop occurs between stations.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-40.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;As we continue…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-41.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Tokaido score receives many penalties.&lt;/li&gt;
  &lt;li&gt;Therefore, the algorithm determines the trip was on the Keihin-Tohoku Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-42.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For an example trip 2 that also starts at Tokyo…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-43.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The train passes the 2nd Keihin-Tohoku station.&lt;/li&gt;
  &lt;li&gt;And the Keihin-Tohoku score receives a penalty.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-44.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;As we continue…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-45.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Keihin-Tohoku score receives many penalties.&lt;/li&gt;
  &lt;li&gt;Therefore, the algorithm determines the trip was on the Tokaido Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for this case.&lt;/li&gt;
  &lt;li&gt;There are many more edge cases.&lt;/li&gt;
  &lt;li&gt;However, let’s continue.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-48.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For each potential railway, we will determine which direction the train is moving.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-49.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Every railway has 2 directions.&lt;/li&gt;
  &lt;li&gt;We’re used to seeing separate timetables on the departure board at a non-terminal station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-50.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For example, the Toyoko Line goes inbound towards Shibuya and outbound towards Yokohama.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-51.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s consider a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; captured on the Toyoko Line going inbound to Shibuya.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-52.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Once we have visited two stations, we can compare the temporal order the station visits.&lt;/li&gt;
  &lt;li&gt;If the visit order matches the order of the stations in the database, we say that the iPhone is heading in the “ascending” direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-53.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The iPhone visited Kikuna and then Okurayama.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-54.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This ordering does not match the database, so we consider it “descending”.&lt;/li&gt;
  &lt;li&gt;In the database, “descending” maps to inbound.&lt;/li&gt;
  &lt;li&gt;Therefore, we know the iPhone is heading inbound to Shibuya.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well for this case.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
  &lt;li&gt;It could take 5 minutes to determine the train direction.&lt;/li&gt;
  &lt;li&gt;Can we do better?&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-56.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;’s course.&lt;/li&gt;
  &lt;li&gt;Remember that course is included with some &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;s by Core Location.&lt;/li&gt;
  &lt;li&gt;Several points moving at a decent speed are required before Core Location adds course to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocation&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;And course itself has its own accuracy value included.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-57.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Core Location provides an estimate of the iPhone’s course in degrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-58.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Note that this is &lt;em&gt;not&lt;/em&gt; the iPhone’s orientation using the compass.&lt;/li&gt;
  &lt;li&gt;The course value should be the same regardless of whether the iPhone is in a pocket or held in a hand facing the rear of the train.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-59.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The course for the example &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; is 359.6 degrees.&lt;/li&gt;
  &lt;li&gt;It’s almost directly North.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-60.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;First, we find the 2 closest stations to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-61.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Next, we calculate the vector between the 2 closest stations for the “ascending” direction in our database.&lt;/li&gt;
  &lt;li&gt;For the Toyoko line, the “ascending” direction is outbound (as mentioned earlier).&lt;/li&gt;
  &lt;li&gt;Therefore the vector goes from Tsunashima to Okurayama.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-62.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We need to take a quick sidebar to talk about the dot product.&lt;/li&gt;
  &lt;li&gt;Do you remember the dot product from math class?&lt;/li&gt;
  &lt;li&gt;We can compare the direction of unit vectors with the dot product.&lt;/li&gt;
  &lt;li&gt;Two vectors facing the same direction have a positive dot product.&lt;/li&gt;
  &lt;li&gt;Two vectors facing in opposite directions have a negative dot product.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-63.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Next, we calculate the dot product between the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;’s course vector and the stations vector.&lt;/li&gt;
  &lt;li&gt;If the dot product is positive, then the railway direction is “ascending”.&lt;/li&gt;
  &lt;li&gt;If the dot product is negative, then the railway direction is “descending”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-65.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The dot product is -0.95.&lt;/li&gt;
  &lt;li&gt;It’s negative.&lt;/li&gt;
  &lt;li&gt;Negative means “descending”.&lt;/li&gt;
  &lt;li&gt;And “descending” in our database maps to inbound for the Toyoko Line.&lt;/li&gt;
  &lt;li&gt;Therefore, the iPhone is heading to Shibuya.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well.&lt;/li&gt;
  &lt;li&gt;Let’s move on to the last part of the algorithm.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-67.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Finally, we can determine the next station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-68.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The next station is shown on the train information display.&lt;/li&gt;
  &lt;li&gt;We’ll call this the “focus station phase” going forward.&lt;/li&gt;
  &lt;li&gt;This includes the station name (e.g. Kikuna) and its phase (e.g. Next).&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-69.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The display cycles through next, soon, and now phases for each station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-70.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;On a map, here is where we will show each phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-71.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We calculate the distance &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;d&lt;/code&gt; and direction vector &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;c&lt;/code&gt; from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; to the closest station.&lt;/li&gt;
  &lt;li&gt;We show the closest station &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;S&lt;/code&gt; or the next station in the travel direction &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;S+1&lt;/code&gt; depending on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;d&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;c&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-72.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;When the closest station is in the travel direction, the phase will be “next”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-73.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; less than 500m from the station will be “soon”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-74.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; less than 200m from the station will be “now”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-75.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Even though the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; is within 500m from the closest station, the station is not in the travel direction.&lt;/li&gt;
  &lt;li&gt;Therefore, the phase will be “next” for the next station in the travel direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well.&lt;/li&gt;
  &lt;li&gt;But…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-77.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;GPS data is unreliable.&lt;/li&gt;
  &lt;li&gt;Especially within big stations.&lt;/li&gt;
  &lt;li&gt;Especially when not moving.&lt;/li&gt;
  &lt;li&gt;Here is an example &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; stopped inside Kawasaki station that has an abysmal 1 km accuracy.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-78.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Let’s create a history of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s for each station.&lt;/li&gt;
  &lt;li&gt;For each station, let’s categorize each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; according to its distance and direction.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-79.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In this example, “approaching” points are orange, “visiting” points are green, and the departure point is “red”.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-80.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Focus station algorithm version 2 has 3 steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-81.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In step 1, we categorize a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; as “visiting” or “approaching” if it lies within the bounds of a Station.&lt;/li&gt;
  &lt;li&gt;Our rule is that only 1 Station per Railway will store a unique &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; array.&lt;/li&gt;
  &lt;li&gt;Usually, this is not an issue, but some Stations on the same Railway are within 200m of each other.&lt;/li&gt;
  &lt;li&gt;To disambiguate, we always choose the closest Station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-82.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; is outside the bounds of any Station that already has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; as non-empty, we set the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt; for that Station.&lt;/li&gt;
  &lt;li&gt;It’s okay for a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; to be set as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt; for Station A while also being in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; array of Station B.&lt;/li&gt;
  &lt;li&gt;Additionally, there is special handling for the startup case where a railway has no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s set yet. In this case, we try to find the closest &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Station&lt;/code&gt; opposite the travel direction and set its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;We can then consider that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Station&lt;/code&gt; the user’s departure station and use it to determine the focus station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-83.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In step 2, we use the station history to calculate the phase for each station.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-84.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is a departure phase for Minami-Senju station.&lt;/li&gt;
  &lt;li&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StationDirectionalLocationHistory&lt;/code&gt; has only a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-85.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is an approaching phase for Kita-Senju station.&lt;/li&gt;
  &lt;li&gt;Note: this would still count as an approaching phase even if there were only 1 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;approachingLocations&lt;/code&gt; array.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-86.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is a visiting phase.&lt;/li&gt;
  &lt;li&gt;Note: this would still count as a visiting phase even if there were only 1 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;visitingLocations&lt;/code&gt; array.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-87.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;This is a visited phase.&lt;/li&gt;
  &lt;li&gt;You can see the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firstDepartureLocation&lt;/code&gt; in red.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-88.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In step 3, we look through the station phase history for all stations to determine the focus station phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-89a.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In an example, when the latest phase for Kawasaki station is visited, then the focus phase is “Next: Kamata”&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-89b.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;In another example, when the latest station phase for Musashi-Kosugi station is visited and Motosumiyoshi station is approaching, then the focus phase is “Soon: Motosumiyoshi”&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-90.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Using a state machine gives us more stable results.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-applause.mp4&quot; autoplay=&quot;&quot; loop=&quot;&quot; preload=&quot;true&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;We did it!&lt;/li&gt;
  &lt;li&gt;Our algorithm works well…&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-91.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But can we tell the difference between a visited station and a passed station?&lt;/li&gt;
  &lt;li&gt;Remember, we need this information to calculate a potential penalty for the railway score.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-92.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If the train is stopped within a station’s bounds for more than 20 seconds then we consider it visited.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-93.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If the train is moving within a station’s bounds for more than 70 seconds then we also consider it visited.&lt;/li&gt;
  &lt;li&gt;This case is for stations with bad GPS reception.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-94.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Otherwise we consider the station as passed.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-95a.mp4&quot; controls=&quot;&quot; preload=&quot;false&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;Now I’d like to demo the SessionViewer macOS app I created.&lt;/li&gt;
  &lt;li&gt;I’ll show a journey from Kannai station to Kawasaki station on the Keihin-Tohoku line.&lt;/li&gt;
  &lt;li&gt;It takes some time for all &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s to be processed by the algorithm (top right).&lt;/li&gt;
  &lt;li&gt;But while it’s processing, I can start playback to see the journey at 10x speed (top right).&lt;/li&gt;
  &lt;li&gt;In the inspector (right sidebar), you can see the algorithm’s results updating.&lt;/li&gt;
  &lt;li&gt;Keihin-Tohoku line has the highest score (top right).&lt;/li&gt;
  &lt;li&gt;The direction is northbound (top right).&lt;/li&gt;
  &lt;li&gt;The latest phase for each station is shown (middle right).&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;video src=&quot;/images/eki-live-presentation-95b.mp4&quot; controls=&quot;&quot; preload=&quot;false&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;

&lt;ul&gt;
  &lt;li&gt;When we reach the last &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Session&lt;/code&gt;, we can see the full Station history (middle right).&lt;/li&gt;
  &lt;li&gt;We can see the phase history for any station by clicking its current phase.&lt;/li&gt;
  &lt;li&gt;When I click on a station, I can see on the map the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt;s that were used to calculate its phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-96.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The 5 iOS apps I created to collect this data are &lt;a href=&quot;https://github.com/twocentstudios/train-tracker-talk&quot;&gt;open source on GitHub&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;The macOS app and algorithm are included as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-97.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The algorithm is still being improved!&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-98.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;But if you want to try it, Eki Live is on the &lt;a href=&quot;https://apps.apple.com/app/id6745218674&quot;&gt;App Store&lt;/a&gt; now.&lt;/li&gt;
  &lt;li&gt;The app starts up automatically in the background and shows the next station in the Dynamic Island.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;img src=&quot;/images/eki-live-presentation-99.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Thanks for reading this presentation.&lt;/li&gt;
  &lt;li&gt;If you have questions or comments, feel free to reach out.&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Mon, 22 Sep 2025 15:00:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/09/22/lets-write-a-train-tracking-algorithm/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/09/22/lets-write-a-train-tracking-algorithm/</guid>
        
        <category>ekilive</category>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>presentation</category>
        
        
      </item>
    
  </channel>
</rss>
