Jekyll2021-08-27T18:37:31+02:00https://steipete.com/feed.xmlsteipete’s blogI founded and bootstrapped PSPDFKit, an SDK for working with PDF files on any platform. I speak at various conferences around the world. Co-organizer Cocoaheads Austria.Peter SteinbergerTop-Level Menu Visibility in SwiftUI for macOS2021-04-09T18:00:00+02:002021-04-09T18:00:00+02:00https://steipete.com/posts/top-level-menu-visibility-in-swiftui<style type="text/css">
div.post-content > img:first-child { display:none; }
</style>
<p>Pretty much all Mac apps have a semi-hidden Debug menu that can be triggered via a user defaults entry or via settings. Naturally I wanted to add the same in my latest project. I’m building a new “universal” app (meaning iOS <em>and</em> macOS), supporting only the latest OSes, so I can using the new SwiftUI app lifecycle.</p>
<p>SwiftUI is really a lot of fun to work with. Sure, <a href="/posts/state-of-swiftui/">there are bugs, warts</a> and parts that simply aren’t finished yet, especially on the Mac, but overall what Apple built here is really great, and it’s so much faster to build apps with it. SwiftUI makes the hard things simple, and sometimes it makes the simple things hard.</p>
<h2 id="menus-in-swiftui-app-lifecycle">Menus in SwiftUI App Lifecycle</h2>
<p>Let’s look at a typical menu definition in the new Big Sur/iOS 14 SwiftUI App Lifecycle. The syntax is straightforward and fits right into the concepts of SwiftUI. Bingings work as well and menus change on-demand as state changes.</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
</pre></td><td class="rouge-code"><pre><span class="kd">@main</span>
<span class="kd">struct</span> <span class="kt">SampleApp</span><span class="p">:</span> <span class="kt">App</span> <span class="p">{</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">Scene</span> <span class="p">{</span>
<span class="kt">WindowGroup</span> <span class="p">{</span>
<span class="kt">MainAppView</span><span class="p">()</span>
<span class="p">}</span>
<span class="o">.</span><span class="n">commands</span> <span class="p">{</span>
<span class="kt">CommandGroup</span><span class="p">(</span><span class="nv">replacing</span><span class="p">:</span> <span class="kt">CommandGroupPlacement</span><span class="o">.</span><span class="n">newItem</span><span class="p">)</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="s">"Import Archive"</span><span class="p">)</span> <span class="p">{</span>
<span class="n">activeSheet</span> <span class="o">=</span> <span class="o">.</span><span class="n">importer</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">keyboardShortcut</span><span class="p">(</span><span class="kt">KeyEquivalent</span><span class="p">(</span><span class="s">"i"</span><span class="p">),</span> <span class="nv">modifiers</span><span class="p">:</span> <span class="o">.</span><span class="n">command</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>There’s a superb guide over at <a href="https://troz.net/post/2021/swiftui_mac_menus/">TrozWare about SwifUI Mac Menus</a> that explains everything in detail - including a way how to move the menu logic into a separate file. Highly recommended. Let’s move on to the interesting bits.</p>
<h2 id="showing-menus-conditionally">Showing Menus Conditionally</h2>
<p>Within <code class="language-plaintext highlighter-rouge">CommandMenu</code> it’s easy to use <code class="language-plaintext highlighter-rouge">if</code>/<code class="language-plaintext highlighter-rouge">else</code> to conditionally show menu entries. SwiftUI uses <code class="language-plaintext highlighter-rouge">@ViewBuilder</code> as resultbuilder and conditionals are correctly implemented.</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="kt">CommandMenu</span><span class="p">(</span><span class="s">"Animals"</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if</span> <span class="n">user</span><span class="o">.</span><span class="n">likesCats</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="s">"Show Cat Picture"</span><span class="p">)</span> <span class="p">{</span> <span class="p">}</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="s">"Show Dog Picture"</span><span class="p">)</span> <span class="p">{</span> <span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>However if we try the same at the top level, we get an error: <code class="language-plaintext highlighter-rouge">"Closure containing control flow statement cannot be used with result builder 'CommandsBuilder'"</code>gs. The SwiftUI-team didn’t implement any branching logic into the <code class="language-plaintext highlighter-rouge">@CommandsBuilder</code>.</p>
<p><img src="/assets/img/2021/top-level-menu-visibility-swiftui/flow-statement.png" alt="Closure containing control flow statement cannot be used with result builder 'CommandsBuilder'" /></p>
<p>After <a href="https://twitter.com/steipete/status/1380518850073092096?s=21">a discussion on Twitter</a>, there really doesn’t seem a SwiftUI-way to trigger the visibility of top-level menus. @LeoNatan suggested to <a href="https://twitter.com/leonatan/status/1380545179157925888?s=21">drop back into AppKit</a>, and that’s what I ended up doing:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre><span class="kd">static</span> <span class="kd">func</span> <span class="nf">triggerDebugMenuVisibilityHack</span><span class="p">()</span> <span class="p">{</span>
<span class="k">if</span> <span class="k">let</span> <span class="nv">mainMenu</span> <span class="o">=</span> <span class="kt">NSApp</span><span class="o">.</span><span class="n">mainMenu</span> <span class="p">{</span>
<span class="kt">DispatchQueue</span><span class="o">.</span><span class="n">main</span><span class="o">.</span><span class="n">async</span> <span class="p">{</span>
<span class="k">if</span> <span class="o">!</span><span class="kt">Features</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">isDebugModeEnabled</span><span class="p">,</span>
<span class="k">let</span> <span class="nv">debugMenu</span> <span class="o">=</span> <span class="n">mainMenu</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">title</span> <span class="o">==</span> <span class="s">"Debug"</span> <span class="p">})</span> <span class="p">{</span>
<span class="n">mainMenu</span><span class="o">.</span><span class="nf">removeItem</span><span class="p">(</span><span class="n">debugMenu</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Make sure to trigger this both on app start and whenever the debug value changes:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="o">.</span><span class="n">onAppear</span> <span class="p">{</span>
<span class="kt">DebugMenuCommands</span><span class="o">.</span><span class="nf">triggerDebugMenuVisibilityHack</span><span class="p">()</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">features</span><span class="o">.</span><span class="n">isDebugModeEnabled</span><span class="p">)</span> <span class="p">{</span> <span class="n">_</span> <span class="k">in</span>
<span class="kt">DebugMenuCommands</span><span class="o">.</span><span class="nf">triggerDebugMenuVisibilityHack</span><span class="p">()</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>And that’s it. Toggling the menu works just as expected. In our update method we have to skip a runloop so the SwiftUI glue has time to set up the menu, however this gets called so early in the app startup lifecycle that it’s not visible. So while not the most elegant solution, this works perfectly fine.</p>
<h2 id="conclusion">Conclusion</h2>
<p>This is a good reminder that even when writing a “Pure SwiftUI” application, the underlying frameworks are there and can help you whenever you run into a limitation of SwiftUI. Since this feels like an omission, I’ve opened a radar (FB9074334) for the SwiftUI team.</p>Peter SteinbergerFixing keyboardShortcut in SwiftUI2021-01-31T12:30:00+01:002021-01-31T12:30:00+01:00https://steipete.com/posts/fixing-keyboardshortcut-in-swiftui<p>iOS 14 introduced <code class="language-plaintext highlighter-rouge">keyboardShortcut</code>, a convenient native way to add keyboard shortcuts to SwiftUI. However, if you end up using it, it likely won’t work. I was curious why that is, so follow along with me for a round of SwiftUI debugging! Spoiler: The workaround is at the end of this article.</p>
<h2 id="behavior-inventory">Behavior Inventory</h2>
<p>Let’s first check out how this feature works:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="kd">struct</span> <span class="kt">ContentView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="s">"Keyboard Enabled Button"</span><span class="p">)</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"P pressed"</span><span class="p">)</span>
<span class="p">}</span><span class="o">.</span><span class="nf">keyboardShortcut</span><span class="p">(</span><span class="s">"p"</span><span class="p">,</span> <span class="nv">modifiers</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">command</span><span class="p">])</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Simple enough. However, when I tried, it didn’t work in production. After simplifying my setup and eventually writing my own example project, I eventually realized my code is correct and this just doesn’t work.</p>
<p>But surely Apple tested this? Let’s try a few combinations:</p>
<ul>
<li>UIKit app lifecycle, iOS 14: ❌</li>
<li>UIKit app lifecycle, Catalyst Big Sur: ✅<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></li>
<li>SwiftUI app lifecycle, iOS 14: ✅</li>
<li>SwiftUI app lifecycle, Catalyst Big Sur: ✅</li>
</ul>
<p>Right. So things work pretty much everywhere, but not in the use case that will likely be the most common one: when mixing SwiftUI and UIKit.</p>
<h2 id="the-solution">The Solution</h2>
<p>I’ve been discussing this on Twitter and was quickly <a href="https://twitter.com/1mtsrodrigues/status/1355555597354225665?s=21">given a workaround (thanks Mateus!)</a> to try:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="k">let</span> <span class="nv">host</span> <span class="o">=</span> <span class="kt">UIHostingController</span><span class="p">(</span><span class="nv">rootView</span><span class="p">:</span> <span class="kt">SwiftUIView</span><span class="p">())</span>
<span class="c1">// Workaround for `keyboardShortcut` not working:</span>
<span class="k">let</span> <span class="nv">window</span> <span class="o">=</span> <span class="kt">UIWindow</span><span class="p">()</span>
<span class="n">window</span><span class="o">.</span><span class="n">rootViewController</span> <span class="o">=</span> <span class="n">host</span>
<span class="n">window</span><span class="o">.</span><span class="nf">makeKeyAndVisible</span><span class="p">()</span>
<span class="nf">present</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="nv">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">completion</span><span class="p">:</span> <span class="kc">nil</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>And sure enough — this indeed does the trick! But it got me even more curious. Mateus talks about a <code class="language-plaintext highlighter-rouge">keyboardShortcutBridge</code> in <code class="language-plaintext highlighter-rouge">UIHostingController</code> that takes care of keyboard management. Let’s see if we can verify that in LLDB:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre>Printing description of host:
<_TtGC7SwiftUI19UIHostingControllerV15SwiftUIKeyboard11SwiftUIView_: 0x7fc4f4408360>
(lldb) expression -l objc -O -- [0x7fc4f4408360 _ivarDescription]
<_TtGC7SwiftUI19UIHostingControllerV15SwiftUIKeyboard11SwiftUIView_: 0x7fc4f4408360>:
in _TtGC7SwiftUI19UIHostingControllerV15SwiftUIKeyboard11SwiftUIView_:
allowedBehaviors (): Value not representable,
host (): Value not representable,
customTabItem (): Value not representable,
toolbarCoordinator (): Value not representable,
swiftUIToolbar (): Value not representable,
toolbarBridge (): Value not representable,
keyboardShortcutBridge (): Value not representable,
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Good old <code class="language-plaintext highlighter-rouge">_ivarDescription</code> is still useful and shows Swift ivars as well; it can’t show the real type, but it’s good enough to confirm that there’s indeed a <code class="language-plaintext highlighter-rouge">keyboardShortcutBridge</code>.</p>
<h2 id="what-sets-keyboardshortcutbridge">What Sets keyboardShortcutBridge?</h2>
<p>Now let’s look at what sets <code class="language-plaintext highlighter-rouge">keyboardShortcutBridge</code>. It seems there’s a code path where this object isn’t set, so let’s find out if that’s the case. When we load SwiftUI’s binary in Hopper and search for this name, we find quite a few matches:</p>
<p><img src="/assets/img/2021/fixing-keyboardshortcut-in-swiftui/keyboardShortcutBridge.png" alt="Hopper search for keyboardShortcutBridge" /></p>
<p>Now let’s analyze what we see here:</p>
<ul>
<li>There’s a class named <code class="language-plaintext highlighter-rouge">KeyboardShortcutBridge</code> in SwiftUI.</li>
<li>It has one method marked <code class="language-plaintext highlighter-rouge">@objc</code>: <code class="language-plaintext highlighter-rouge">_performShortcutKeyCommand:</code>, therefore Objective-C metadata is emitted (init, cxx_destruct).</li>
<li>It uses <code class="language-plaintext highlighter-rouge">UIKeyCommand</code> under the hood, which is an API you’ll be familiar with if you’ve ever added keyboard support on iOS.</li>
<li>There’s a setter for the Swift property that sets this object: <code class="language-plaintext highlighter-rouge">SwiftUI.UIHostingController.keyboardShortcutBridge.setter</code>.</li>
</ul>
<p>Using Xcode’s breakpoint list isn’t working too well for SwiftUI. Adding the setter there isn’t working (fully qualified). Instead, let’s try using LLDB directly and fuzzy-searching for the breakpoint. You’ll want to stop your program early (before <code class="language-plaintext highlighter-rouge">UIHostingController</code> is created) and add the breakpoint manually:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>(lldb) breakpoint set --func-regex keyboardShortcutBridge
Breakpoint 2: 3 locations.
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Great! Now let’s look at the three matches:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre>(lldb) breakpoint list
Current breakpoints:
1: file = '/Users/steipete/Projects/TempProjects/SwiftUIKeyboard/SwiftUIKeyboard/ViewController.swift', line = 22, exact_match = 0, locations = 1, resolved = 1, hit count = 1
1.1: where = SwiftUIKeyboard`SwiftUIKeyboard.ViewController.viewDidAppear(Swift.Bool) -> () + 164 at ViewController.swift:22:20, address = 0x000000010115b444, resolved, hit count = 1
2: regex = 'keyboardShortcutBridge', locations = 3, resolved = 3, hit count = 0
2.1: where = SwiftUI`generic specialization <SwiftUI.ModifiedContent<SwiftUI.AnyView, SwiftUI.RootModifier>> of SwiftUI.UIHostingController.keyboardShortcutBridge.setter : Swift.Optional<SwiftUI.KeyboardShortcutBridge>, address = 0x00007fff57a376b0, resolved, hit count = 0
2.2: where = SwiftUI`SwiftUI.UIHostingController.keyboardShortcutBridge.getter : Swift.Optional<SwiftUI.KeyboardShortcutBridge>, address = 0x00007fff57a59960, resolved, hit count = 0
2.3: where = SwiftUI`SwiftUI.UIHostingController.keyboardShortcutBridge.setter : Swift.Optional<SwiftUI.KeyboardShortcutBridge>, address = 0x00007fff57a59990, resolved, hit count = 0
</pre></td></tr></tbody></table></code></pre></div></div>
<p>That’s good enough. And sure enough — in the non-working case, the setter is never hit. Once we apply the workaround, the method is hit:</p>
<p><img src="/assets/img/2021/fixing-keyboardshortcut-in-swiftui/keyboardShortcutBridge-setter.png" alt="Xcode backtrace for keyboardShortcutBridge" /></p>
<p>We see that the code responsible for calling the setter is in <code class="language-plaintext highlighter-rouge">didChangeAllowedBehaviors</code>.</p>
<p>Next, let’s see if there are any other places that would call this setter. I like to use a full pseudo-code export of SwiftUI. You can create this via Hopper > File > Produce Pseudo-Code File For All Procedures…. This will take many hours and produce a file named <code class="language-plaintext highlighter-rouge">SwiftUI.m</code> that’s more than 100 MB in size. Once this is done, use a text editor that can open large files,<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> and search for <code class="language-plaintext highlighter-rouge">SwiftUI.UIHostingController.keyboardShortcutBridge.setter</code>. The only two code paths are these:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">int _$s7SwiftUI19UIHostingControllerC25didChangeAllowedBehaviors4from2toyAC0gH0Vyx_G_AItF(int arg0)</code></li>
<li><code class="language-plaintext highlighter-rouge">void _$s7SwiftUI19UIHostingControllerC25didChangeAllowedBehaviors4from2toyAC0gH0Vyx_G_AItFAA15ModifiedContentVyAA7AnyViewVAA12RootModifierVG_Tg5(int arg0)</code></li>
</ul>
<p>This is mangled Swift, but it’s not hard to see what the unmangled function name is called — it’s our <code class="language-plaintext highlighter-rouge">didChangeAllowedBehaviors(from:to")</code> with a lambda inside it, and not anywhere else.</p>
<h2 id="what-triggers-didchangeallowedbehaviors">What Triggers didChangeAllowedBehaviors?</h2>
<p>What triggers an allowed behavior change? We can search for <code class="language-plaintext highlighter-rouge">SwiftUI.UIHostingController.allowedBehaviors.setter</code>, since <code class="language-plaintext highlighter-rouge">didChangeAllowedBehaviors</code> is triggered when the setter is invoked:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">_$s7SwiftUI16AppSceneDelegateC9sceneItemAA0D4ListV0G0VyF()</code></li>
<li><code class="language-plaintext highlighter-rouge">_$s7SwiftUI16RootViewDelegateC07hostingD0_9didMoveToyAA010_UIHostingD0CyxG_So8UIWindowCSgtAA0D0RzlF(int arg0, int arg1)</code></li>
</ul>
<p>So there are two mechanisms that trigger this:</p>
<ul>
<li>The SwiftUI-based app lifecycle</li>
<li>A root view delegate</li>
</ul>
<p>This lines up with our previous tests. SwiftUI app lifecycle works, and if we add <code class="language-plaintext highlighter-rouge">UIHostingController</code> as a root view controller, the <code class="language-plaintext highlighter-rouge">RootViewDelegate</code> also triggers the change. We can check via a fuzzy breakpoint if a <code class="language-plaintext highlighter-rouge">RootViewDelegate</code> is created in the non-working variant via <code class="language-plaintext highlighter-rouge">breakpoint set --func-regex RootViewDelegate</code>, and sure enough, there are 13 matches, but not one fires.</p>
<p>When searching for <code class="language-plaintext highlighter-rouge">RootViewDelegate(</code> in the full-text <code class="language-plaintext highlighter-rouge">SwiftUI.m</code> file, there’s only one match, in <code class="language-plaintext highlighter-rouge">s7SwiftUI14_UIHostingViewC15didMoveToWindowyyF</code>. This further confirms our theory. It seems Apple simply forgot a code path to create the keyboard shortcut bridge for the most likely use case of using SwiftUI in existing UIKit apps, which is where it makes most sense.</p>
<h2 id="tweaking-the-workaround">Tweaking the Workaround</h2>
<p>We can make the workaround slightly better and pack it into an extension. If we avoid making the temporary window key, we can skip a whole class of issues that appear when the key window is unexpectedly changed:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="kd">extension</span> <span class="kt">UIHostingController</span> <span class="p">{</span>
<span class="c1">/// Applies workaround so `keyboardShortcut` can be used via SwiftUI.</span>
<span class="c1">///</span>
<span class="c1">/// When `UIHostingController` is used as a non-root controller with the UIKit app lifecycle,</span>
<span class="c1">/// keyboard shortcuts created in SwiftUI don't work (as of iOS 14.4).</span>
<span class="c1">/// This workaround is harmless and triggers an internal state change that enables keyboard shortcut bridging.</span>
<span class="c1">/// See https://steipete.com/posts/fixing-keyboardshortcut-in-swiftui/</span>
<span class="kd">func</span> <span class="nf">applyKeyboardShortcutFix</span><span class="p">()</span> <span class="p">{</span>
<span class="cp">#if !targetEnvironment(macCatalyst)</span>
<span class="k">let</span> <span class="nv">window</span> <span class="o">=</span> <span class="kt">UIWindow</span><span class="p">()</span>
<span class="n">window</span><span class="o">.</span><span class="n">rootViewController</span> <span class="o">=</span> <span class="k">self</span>
<span class="n">window</span><span class="o">.</span><span class="n">isHidden</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="cp">#endif</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Because the window itself is shown and deallocated within the same run loop, it’ll never be visible. This workaround is safe and only uses public APIs. I reported this issue to Apple via FB8984997. <a href="https://github.com/PSPDFKit-labs/radar.apple.com/commit/8768d5c9fecd602625cc10b7a7c98f2bbc0cda4a">You can read the full bug report and sample project here</a>.</p>
<h2 id="bonus-build-keyboardshortcut-for-ios-13">Bonus: Build keyboardShortcut for iOS 13</h2>
<p>After fixing the iOS 14 version of keyboard shortcut, I realized that the principle is quite simple, and it can be rewritten in around 100 lines of Swift so that this feature is available on iOS 13 as well. The syntax is practically the same:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Button Tapped!!"</span><span class="p">)</span>
<span class="p">})</span> <span class="p">{</span>
<span class="kt">Text</span><span class="p">(</span><span class="s">"Button"</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">keyCommand</span><span class="p">(</span><span class="s">"e"</span><span class="p">,</span> <span class="nv">modifiers</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">control</span><span class="p">])</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p><a href="https://gist.github.com/steipete/03d412f3752611f8f4554372a29cc29d">You can read the full gist here</a>.</p>
<h2 id="conclusion">Conclusion</h2>
<p>I hope this post helps folks when they google “keyboardShortcut SwiftUI not working,” provides a safe workaround, and inspires a few people to dig deeper. Swift is harder to reverse engineer than Objective-C is, but it’s still possible. This was the first time I had to set breakpoints for binary Swift symbols, so it’s good to see that this still works when using LLDB manually.</p>
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>I’ve seen issues with keyboard handling in Catalyst, so I recommend testing everything before you rely on this functionality there. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>In the early years, Sublime Text was my editor of choice, but nowadays, the Electron-based Visual Studio Code is way faster in both opening and searching this file and those of a similar size. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>Peter SteinbergeriOS 14 introduced keyboardShortcut, a convenient native way to add keyboard shortcuts to SwiftUI. However, if you end up using it, it likely won’t work. I was curious why that is, so follow along with me for a round of SwiftUI debugging! Spoiler: The workaround is at the end of this article.Supporting Both Tap and Long Press on a Button in SwiftUI2021-01-27T17:30:00+01:002021-01-27T17:30:00+01:00https://steipete.com/posts/supporting-both-tap-and-longpress-on-button-in-swiftui<p>My task today was quite simple: adding an optional long-press handler to a button in SwiftUI. A regular tap opens our website and a long press does… something else. Not so difficult, right?</p>
<h2 id="naive-first-version">Naive First Version</h2>
<p>Here’s my first naive iteration:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre><span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="nf">openWebsite</span><span class="p">(</span><span class="o">.</span><span class="n">pspdfkit</span><span class="p">)</span>
<span class="p">})</span> <span class="p">{</span>
<span class="kt">Image</span><span class="p">(</span><span class="s">"pspdfkit-powered"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">renderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">template</span><span class="p">)</span>
<span class="o">.</span><span class="nf">onLongPressGesture</span><span class="p">(</span><span class="nv">minimumDuration</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Secret Long Press Action!"</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>While the above works to detect a long press, when adding a gesture to the image, the button no longer fires. Alright, not quite what we want. Let’s move the gesture out of the label and to the button.</p>
<h2 id="moving-things-around-version">Moving Things Around Version</h2>
<p>Here’s my next attempt:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre><span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="nf">openWebsite</span><span class="p">(</span><span class="o">.</span><span class="n">pspdfkit</span><span class="p">)</span>
<span class="p">})</span> <span class="p">{</span>
<span class="kt">Image</span><span class="p">(</span><span class="s">"pspdfkit-powered"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">renderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">template</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">onLongPressGesture</span><span class="p">(</span><span class="nv">minimumDuration</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Secret Long Press Action!"</span><span class="p">)</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Great! Now the button tap works again — unfortunately the long-press gesture doesn’t work anymore. OK, let’s use <code class="language-plaintext highlighter-rouge">simultaneousGesture</code> to tell SwiftUI that we really care about both gestures.</p>
<h2 id="getting-fancy-with-simultaneousgesture">Getting Fancy with simultaneousGesture</h2>
<p>Take three:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre><span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="nf">openWebsite</span><span class="p">(</span><span class="o">.</span><span class="n">pspdfkit</span><span class="p">)</span>
<span class="p">})</span> <span class="p">{</span>
<span class="kt">Image</span><span class="p">(</span><span class="s">"pspdfkit-powered"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">renderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">template</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">simultaneousGesture</span><span class="p">(</span><span class="kt">LongPressGesture</span><span class="p">()</span><span class="o">.</span><span class="n">onEnded</span> <span class="p">{</span> <span class="n">_</span> <span class="k">in</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Secret Long Press Action!"</span><span class="p">)</span>
<span class="p">})</span>
<span class="kt">Spacer</span><span class="p">()</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Great — that works. However, now we always trigger both the long press and the action, which isn’t quite what we want. We want either/or, so let’s try adding a second gesture instead.</p>
<h2 id="two-gestures-are-better-than-one">Two Gestures Are Better Than One</h2>
<p>Here we go again:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="c1">// ignore</span>
<span class="p">})</span> <span class="p">{</span>
<span class="kt">Image</span><span class="p">(</span><span class="s">"pspdfkit-powered"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">renderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">template</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">simultaneousGesture</span><span class="p">(</span><span class="kt">LongPressGesture</span><span class="p">()</span><span class="o">.</span><span class="n">onEnded</span> <span class="p">{</span> <span class="n">_</span> <span class="k">in</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Secret Long Press Action!"</span><span class="p">)</span>
<span class="p">})</span>
<span class="o">.</span><span class="nf">simultaneousGesture</span><span class="p">(</span><span class="kt">TapGesture</span><span class="p">()</span><span class="o">.</span><span class="n">onEnded</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Boring regular tap"</span><span class="p">)</span>
<span class="nf">openWebsite</span><span class="p">(</span><span class="o">.</span><span class="n">pspdfkit</span><span class="p">)</span>
<span class="p">})</span>
<span class="kt">Spacer</span><span class="p">()</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>It… works! It does exactly what we expect, and it’s nicely calling either tap or long press. Woohoo! So let’s do some QA and test everywhere. iOS 13: check. iOS 14: check. Let’s compile the Catalyst version to be sure. And: It does not work. Neither tap nor long tap. The button has no effect at all.</p>
<h2 id="catalyst-always-catalyst">Catalyst… Always Catalyst!</h2>
<p>If we can ignore the long press on Catalyst, then this combination works at least for the regular action:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
</pre></td><td class="rouge-code"><pre> <span class="kd">@State</span> <span class="k">var</span> <span class="nv">didLongPress</span> <span class="o">=</span> <span class="kc">false</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="k">if</span> <span class="n">didLongPress</span> <span class="p">{</span>
<span class="n">didLongPress</span> <span class="o">=</span> <span class="kc">false</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Boring regular tap"</span><span class="p">)</span>
<span class="nf">openWebsite</span><span class="p">(</span><span class="o">.</span><span class="n">pspdfkit</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">})</span> <span class="p">{</span>
<span class="kt">Image</span><span class="p">(</span><span class="s">"pspdfkit-powered"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">renderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">template</span><span class="p">)</span>
<span class="p">}</span>
<span class="c1">// None of this ever fires on Mac Catalyst :(</span>
<span class="o">.</span><span class="nf">simultaneousGesture</span><span class="p">(</span><span class="kt">LongPressGesture</span><span class="p">()</span><span class="o">.</span><span class="n">onEnded</span> <span class="p">{</span> <span class="n">_</span> <span class="k">in</span>
<span class="n">didLongPress</span> <span class="o">=</span> <span class="kc">true</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Secret Long Press Action!"</span><span class="p">)</span>
<span class="p">})</span>
<span class="o">.</span><span class="nf">simultaneousGesture</span><span class="p">(</span><span class="kt">TapGesture</span><span class="p">()</span><span class="o">.</span><span class="n">onEnded</span> <span class="p">{</span>
<span class="n">didLongPress</span> <span class="o">=</span> <span class="kc">false</span>
<span class="p">})</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>In our case, we really want the long press though, so what to do? I remembered a trick I used in my <a href="https://pspdfkit.com/blog/2020/popovers-from-swiftui-uibarbutton/">Presenting Popovers from SwiftUI</a> article: We can use a <code class="language-plaintext highlighter-rouge">ZStack</code> and just use UIKit for what doesn’t work in SwiftUI.</p>
<h2 id="the-nuclear-option">The Nuclear Option</h2>
<p>The use is simple:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="kt">LongPressButton</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="nf">openWebsite</span><span class="p">(</span><span class="o">.</span><span class="n">pspdfkit</span><span class="p">)</span>
<span class="p">},</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="p">{</span>
<span class="nf">print</span><span class="p">(</span><span class="s">"Secret Long Press Action!"</span><span class="p">)</span>
<span class="p">},</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
<span class="kt">Image</span><span class="p">(</span><span class="s">"pspdfkit-powered"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">renderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">template</span><span class="p">)</span>
<span class="p">})</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Now, let’s talk about this <code class="language-plaintext highlighter-rouge">LongPressButton</code> subclass…</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
</pre></td><td class="rouge-code"><pre><span class="kd">struct</span> <span class="kt">LongPressButton</span><span class="o"><</span><span class="kt">Label</span><span class="o">></span><span class="p">:</span> <span class="kt">View</span> <span class="k">where</span> <span class="kt">Label</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">label</span><span class="p">:</span> <span class="p">(()</span> <span class="o">-></span> <span class="kt">Label</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">action</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="k">let</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="nf">init</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">,</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">,</span> <span class="nv">label</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Label</span><span class="p">)</span> <span class="p">{</span>
<span class="k">self</span><span class="o">.</span><span class="n">label</span> <span class="o">=</span> <span class="n">label</span>
<span class="k">self</span><span class="o">.</span><span class="n">action</span> <span class="o">=</span> <span class="n">action</span>
<span class="k">self</span><span class="o">.</span><span class="n">longPressAction</span> <span class="o">=</span> <span class="n">longPressAction</span>
<span class="p">}</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="p">},</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
<span class="kt">ZStack</span> <span class="p">{</span>
<span class="nf">label</span><span class="p">()</span>
<span class="c1">// Using .simultaneousGesture(LongPressGesture().onEnded { _ in works on iOS but fails on Catalyst</span>
<span class="kt">TappableView</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="n">action</span><span class="p">,</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="n">longPressAction</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">})</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">private</span> <span class="kd">struct</span> <span class="kt">TappableView</span><span class="p">:</span> <span class="kt">UIViewRepresentable</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">action</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="k">let</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="kd">typealias</span> <span class="kt">UIViewType</span> <span class="o">=</span> <span class="kt">UIView</span>
<span class="kd">func</span> <span class="nf">makeCoordinator</span><span class="p">()</span> <span class="o">-></span> <span class="kt">TappableView</span><span class="o">.</span><span class="kt">Coordinator</span> <span class="p">{</span>
<span class="kt">Coordinator</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="n">action</span><span class="p">,</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="n">longPressAction</span><span class="p">)</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="nf">makeUIView</span><span class="p">(</span><span class="nv">context</span><span class="p">:</span> <span class="k">Self</span><span class="o">.</span><span class="kt">Context</span><span class="p">)</span> <span class="o">-></span> <span class="kt">UIView</span> <span class="p">{</span>
<span class="kt">UIView</span><span class="p">()</span><span class="o">.</span><span class="n">then</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">tapGestureRecognizer</span> <span class="o">=</span> <span class="kt">UITapGestureRecognizer</span><span class="p">(</span><span class="nv">target</span><span class="p">:</span> <span class="n">context</span><span class="o">.</span><span class="n">coordinator</span><span class="p">,</span>
<span class="nv">action</span><span class="p">:</span> <span class="kd">#selector(</span><span class="nf">Coordinator.handleTap(sender:)</span><span class="kd">)</span><span class="p">)</span>
<span class="nv">$0</span><span class="o">.</span><span class="nf">addGestureRecognizer</span><span class="p">(</span><span class="n">tapGestureRecognizer</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">doubleTapGestureRecognizer</span> <span class="o">=</span> <span class="kt">UILongPressGestureRecognizer</span><span class="p">(</span><span class="nv">target</span><span class="p">:</span> <span class="n">context</span><span class="o">.</span><span class="n">coordinator</span><span class="p">,</span>
<span class="nv">action</span><span class="p">:</span> <span class="kd">#selector(</span><span class="nf">Coordinator.handleLongPress(sender:)</span><span class="kd">)</span><span class="p">)</span>
<span class="n">doubleTapGestureRecognizer</span><span class="o">.</span><span class="n">minimumPressDuration</span> <span class="o">=</span> <span class="mi">2</span>
<span class="n">doubleTapGestureRecognizer</span><span class="o">.</span><span class="nf">require</span><span class="p">(</span><span class="nv">toFail</span><span class="p">:</span> <span class="n">tapGestureRecognizer</span><span class="p">)</span>
<span class="nv">$0</span><span class="o">.</span><span class="nf">addGestureRecognizer</span><span class="p">(</span><span class="n">doubleTapGestureRecognizer</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="nf">updateUIView</span><span class="p">(</span><span class="n">_</span> <span class="nv">uiView</span><span class="p">:</span> <span class="kt">UIView</span><span class="p">,</span> <span class="nv">context</span><span class="p">:</span> <span class="k">Self</span><span class="o">.</span><span class="kt">Context</span><span class="p">)</span> <span class="p">{</span> <span class="p">}</span>
<span class="kd">class</span> <span class="kt">Coordinator</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">action</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="k">let</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="nf">init</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">,</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">)</span> <span class="p">{</span>
<span class="k">self</span><span class="o">.</span><span class="n">action</span> <span class="o">=</span> <span class="n">action</span>
<span class="k">self</span><span class="o">.</span><span class="n">longPressAction</span> <span class="o">=</span> <span class="n">longPressAction</span>
<span class="p">}</span>
<span class="kd">@objc</span> <span class="kd">func</span> <span class="nf">handleTap</span><span class="p">(</span><span class="nv">sender</span><span class="p">:</span> <span class="kt">UITapGestureRecognizer</span><span class="p">)</span> <span class="p">{</span>
<span class="k">guard</span> <span class="n">sender</span><span class="o">.</span><span class="n">state</span> <span class="o">==</span> <span class="o">.</span><span class="n">ended</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="nf">action</span><span class="p">()</span>
<span class="p">}</span>
<span class="kd">@objc</span> <span class="kd">func</span> <span class="nf">handleLongPress</span><span class="p">(</span><span class="nv">sender</span><span class="p">:</span> <span class="kt">UILongPressGestureRecognizer</span><span class="p">)</span> <span class="p">{</span>
<span class="k">guard</span> <span class="n">sender</span><span class="o">.</span><span class="n">state</span> <span class="o">==</span> <span class="o">.</span><span class="n">began</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="nf">longPressAction</span><span class="p">()</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>And here we go. This version works exactly as we expect on iOS 13 and iOS 14, and on Catalyst on Catalina and Big Sur. <strong>UIKit is verbose, but it works.</strong> And with the power of SwiftUI, we can hide all that code behind a convenient new button subclass.</p>
<p><a href="https://pspdfkit.com/pdf-sdk/ios/">In our project</a>, this code is much smaller, as we use small categories to allow block-based gesture recognizers and <a href="https://github.com/AvdLee/SwiftUIKitView">automatic wrapping of UIViews</a>:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
</pre></td><td class="rouge-code"><pre><span class="kd">struct</span> <span class="kt">LongPressButton</span><span class="o"><</span><span class="kt">Label</span><span class="o">></span><span class="p">:</span> <span class="kt">View</span> <span class="k">where</span> <span class="kt">Label</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">label</span><span class="p">:</span> <span class="p">(()</span> <span class="o">-></span> <span class="kt">Label</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">action</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="k">let</span> <span class="nv">longPressAction</span><span class="p">:</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="k">let</span> <span class="nv">longPressDelay</span><span class="p">:</span> <span class="kt">TimeInterval</span>
<span class="nf">init</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">,</span> <span class="nv">onLongPress</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">,</span> <span class="nv">longPressDelay</span><span class="p">:</span> <span class="kt">TimeInterval</span> <span class="o">=</span> <span class="mi">2</span><span class="p">,</span> <span class="nv">label</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-></span> <span class="kt">Label</span><span class="p">)</span> <span class="p">{</span>
<span class="k">self</span><span class="o">.</span><span class="n">label</span> <span class="o">=</span> <span class="n">label</span>
<span class="k">self</span><span class="o">.</span><span class="n">action</span> <span class="o">=</span> <span class="n">action</span>
<span class="k">self</span><span class="o">.</span><span class="n">longPressAction</span> <span class="o">=</span> <span class="n">onLongPress</span>
<span class="k">self</span><span class="o">.</span><span class="n">longPressDelay</span> <span class="o">=</span> <span class="n">longPressDelay</span>
<span class="p">}</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
<span class="p">},</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
<span class="kt">ZStack</span> <span class="p">{</span>
<span class="nf">label</span><span class="p">()</span>
<span class="kt">UIViewContainer</span><span class="p">(</span><span class="kt">UIView</span><span class="p">()</span><span class="o">.</span><span class="n">then</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">tapGestureRecognizer</span> <span class="o">=</span> <span class="kt">UITapGestureRecognizer</span><span class="p">(</span><span class="nv">name</span><span class="p">:</span> <span class="s">"Tap"</span><span class="p">)</span> <span class="p">{</span> <span class="n">sender</span> <span class="k">in</span>
<span class="k">guard</span> <span class="n">sender</span><span class="o">.</span><span class="n">state</span> <span class="o">==</span> <span class="o">.</span><span class="n">ended</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="nf">action</span><span class="p">()</span>
<span class="p">}</span>
<span class="nv">$0</span><span class="o">.</span><span class="nf">addGestureRecognizer</span><span class="p">(</span><span class="n">tapGestureRecognizer</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">doubleTapGestureRecognizer</span> <span class="o">=</span> <span class="kt">UILongPressGestureRecognizer</span><span class="p">(</span><span class="nv">name</span><span class="p">:</span> <span class="s">"Long Press"</span><span class="p">)</span> <span class="p">{</span> <span class="n">sender</span> <span class="k">in</span>
<span class="k">guard</span> <span class="n">sender</span><span class="o">.</span><span class="n">state</span> <span class="o">==</span> <span class="o">.</span><span class="n">began</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="nf">longPressAction</span><span class="p">()</span>
<span class="p">}</span>
<span class="n">doubleTapGestureRecognizer</span><span class="o">.</span><span class="n">minimumPressDuration</span> <span class="o">=</span> <span class="n">longPressDelay</span>
<span class="n">doubleTapGestureRecognizer</span><span class="o">.</span><span class="nf">require</span><span class="p">(</span><span class="nv">toFail</span><span class="p">:</span> <span class="n">tapGestureRecognizer</span><span class="p">)</span>
<span class="nv">$0</span><span class="o">.</span><span class="nf">addGestureRecognizer</span><span class="p">(</span><span class="n">doubleTapGestureRecognizer</span><span class="p">)</span>
<span class="p">})</span>
<span class="p">}</span>
<span class="p">})</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="addendum-why-use-button">Addendum: Why Use Button?</h2>
<p>Twitter folks have commented that this would all be much easier if I didn’t use <code class="language-plaintext highlighter-rouge">Button</code> but — like here — the <code class="language-plaintext highlighter-rouge">Image</code> struct directly. This indeed makes the SwiftUI tap gestures work much better, but it also misses out a few neat default features that Button has:</p>
<ul>
<li>Automatically highlighting on tap; then fading that out if the mouse goes too far away</li>
<li>Automatically tinting the image when the window is active and using gray when the window is inactive again (especially noticeable on Catalyst)</li>
<li>Automatically adding some click padding around the content</li>
</ul>
<p>I’ve tried various variations, but it seems <code class="language-plaintext highlighter-rouge">longPress</code> is buggy on Catalyst. If you don’t have to bother with Mac Catalyst, <a href="https://gist.github.com/OskarGroth/d959d15ef96eff19ce433077237e37fb">try following sample code</a>.</p>
<h2 id="conclusion">Conclusion</h2>
<p>So what’s really special about the secret long-press action? It does enable the Debug Mode of <a href="https://pdfviewer.io">PDF Viewer</a>, showing various settings that aren’t really useful for regular folks, but that help with QA testing. If you’re curious, download our app (it’s free), long press on our icon in the Settings footer, and see for yourself.</p>Peter SteinbergerMy task today was quite simple: adding an optional long-press handler to a button in SwiftUI. A regular tap opens our website and a long press does… something else. Not so difficult, right?On Using Apple Silicon Mac Mini for Continuous Integration2020-12-14T10:30:00+01:002020-12-14T10:30:00+01:00https://steipete.com/posts/apple-silicon-mac-mini-for-ci<p>Ever since the M1 was announced, I’ve been curious how well Apple’s new Mac mini would perform for our CI system. A few days ago, we finally got access to two M1 Mac minis hosted on MacStadium (8-core M1, 16 GB unified memory, 1 TB SSD, 1 GbE).</p>
<p>The Geekbench Score is 1705/7379 vs. 1100/5465, so the promise is more than 30 percent increased performance — even more so for single-threaded operations. Linking and code-signing are tasks Apple hasn’t yet parallelized, so single-core performance is a significant factor for CI performance.</p>
<p>A recap: We run a raw Mac mini setup (6-core 3.2 GHz, 64 GB RAM, 1 TB SSD, 10Gbs). If you’re interested, I explored <a href="https://pspdfkit.com/blog/2020/managing-macos-hardware-virtualization-or-bare-metal/">the tradeoffs between virtualization and bare metal on the PSPDFKit blog</a>.</p>
<h2 id="automization-woes">Automization Woes</h2>
<p>We’re using <a href="https://cinc.sh/">Cinc</a> (the open source binary package of <a href="https://www.chef.io/products/chef-automate">Chef</a>) and <a href="https://knife-zero.github.io/">Knife-Zero</a> to automate the setup process for new nodes. It does everything from creating a CI user with an APFS-encrypted drive and configuring firewall rules, to installing dependencies like <a href="https://pspdfkit.com/blog/2020/faster-compilation-with-ccache/">ccache for faster compiling</a> and Ruby for scripting, and installing Xcode and required Simulators. After a few hours, setup is complete and the machine automatically registers itself on Buildkite as a new agent.</p>
<p>There’s a detailed article coming next in our <a href="https://pspdfkit.com/blog/2020/continuous-integration-for-small-ios-macos-teams/">Continuous Integration for Small iOS/macOS Teams</a> series, and it goes into more detail on this setup. Of course, there have been a few issues we encountered along the way to get the automation to work with Apple Silicon.</p>
<h2 id="installing-rosetta-2">Installing Rosetta 2</h2>
<p>The first thing you’ll need to do on the new machines is install Rosetta to enable Intel emulation. This is curiously not the default in Big Sur, but it only takes a few seconds via the terminal:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>/usr/sbin/softwareupdate --install-rosetta --agree-to-license
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="cinc-for-darwinarm">Cinc for Darwin/ARM</h2>
<p>There’s no Chef or <a href="https://cinc.sh/start/client/">Cinc</a> release for Apple Silicon yet, which is both a blessing and a curse. It does make CI easier, since the Cinc client will run in x64 emulation mode, so anything it installs will also default to x64, which is slower but generally works.</p>
<p>Since the install script does platform detection, it’ll fail with an error, as there’s no binary for Cinc available yet. To work around this, modify the <code class="language-plaintext highlighter-rouge">chef_full.erb</code> script in the <a href="https://github.com/chef/chef/blob/master/lib/chef/knife/bootstrap/templates/chef-full.erb">knife</a> gem and add <code class="language-plaintext highlighter-rouge">arch -x86_64</code> before the <code class="language-plaintext highlighter-rouge">sh $tmp_dir/install.sh</code> part. This will ensure the script detects Intel architecture and will download the client.</p>
<p>Careful: The Chef/Cinc situation is tricky. Don’t mindlessly update the gems, as Chef is faster in releasing binaries and overriding all your Cinc binaries, and then nothing works anymore unless you insert dollars or manually remove all Chef gems. There’s also a messy <a href="https://twitter.com/steipete/status/1337712935418929154?s=21">randomness to what is renamed “cinc” and what is “chef.”</a></p>
<h2 id="apfs-containers">APFS Containers</h2>
<p>We automate <code class="language-plaintext highlighter-rouge">diskutil</code> to create a new encrypted volume for the CI user. This ensures our source code is always encrypted. If hardware is replaced, the data is useless. We manually enter the disk password on a power cycle or when the OS reboots because of an update.</p>
<p>On Apple Silicon, the main APFS container is <code class="language-plaintext highlighter-rouge">disk3</code> and not <code class="language-plaintext highlighter-rouge">disk1</code>. Currently, this change is hardcoded; eventually I’ll modify the script to parse <code class="language-plaintext highlighter-rouge">diskutil list</code> to detect the container automatically. It took me quite a while to understand why Cinc stopped with “Error: -69493: You can’t add any more APFS Volumes to its APFS Container.” I mention it here so there’s <a href="https://twitter.com/steipete/status/1337711727023157249?s=21">at least one result on Google with this error</a>. 🙃</p>
<h2 id="detecting-apple-silicon-via-scripts">Detecting Apple Silicon via Scripts</h2>
<p>We use Buildkite as our CI agent, and it recently released <a href="https://github.com/buildkite/agent/releases/tag/v3.26.0">3.26.0 with an experimental native executable for Apple Silicon</a>. It’s running on a prerelease version of Go, but so far, it’s been stable.</p>
<p>There is no universal build, so the download script needs adjustment. To not hardcode this, I’ve been using a trick to detect the <em>real</em> architecture at runtime, since the script runs in Rosetta emulation mode and the usual ways would all report Intel.</p>
<p>Here’s the full block for Ruby. The interesting part is <code class="language-plaintext highlighter-rouge">sysctl -in sysctl.proc_translated</code>. It returns <code class="language-plaintext highlighter-rouge">0</code> if you run on arm, <code class="language-plaintext highlighter-rouge">1</code> if you run on Rosetta 2, and NOTHING if you run on an Intel Mac. Everything else is a dance to get the shell output back into Chef-flavored Ruby:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="n">action_class</span> <span class="k">do</span>
<span class="k">def</span> <span class="nf">download_url</span>
<span class="c1">#tricky way to load this Chef::Mixin::ShellOut utilities</span>
<span class="no">Chef</span><span class="o">::</span><span class="no">Resource</span><span class="o">::</span><span class="no">RubyBlock</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="ss">:include</span><span class="p">,</span> <span class="no">Chef</span><span class="o">::</span><span class="no">Mixin</span><span class="o">::</span><span class="no">ShellOut</span><span class="p">)</span>
<span class="n">command</span> <span class="o">=</span> <span class="s1">'sysctl -in sysctl.proc_translated'</span>
<span class="n">command_out</span> <span class="o">=</span> <span class="n">shell_out</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
<span class="n">architecture</span> <span class="o">=</span> <span class="n">command_out</span><span class="p">.</span><span class="nf">stdout</span> <span class="o">==</span> <span class="s2">""</span> <span class="p">?</span> <span class="s1">'amd64'</span> <span class="p">:</span> <span class="s1">'arm64'</span>
<span class="n">platform</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'mac_os_x'</span><span class="p">,</span> <span class="s1">'macos'</span><span class="p">].</span><span class="nf">include?</span><span class="p">(</span><span class="n">node</span><span class="p">[</span><span class="s1">'platform'</span><span class="p">])</span> <span class="p">?</span> <span class="s1">'darwin'</span> <span class="p">:</span> <span class="s1">'linux'</span>
<span class="s2">"https://github.com/buildkite/agent/releases/download/v</span><span class="si">#{</span><span class="n">new_resource</span><span class="p">.</span><span class="nf">version</span><span class="si">}</span><span class="s2">/buildkite-agent-</span><span class="si">#{</span><span class="n">platform</span><span class="si">}</span><span class="s2">-</span><span class="si">#{</span><span class="n">architecture</span><span class="si">}</span><span class="s2">-</span><span class="si">#{</span><span class="n">new_resource</span><span class="p">.</span><span class="nf">version</span><span class="si">}</span><span class="s2">.tar.gz"</span>
<span class="k">end</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>The best part: This will still work, even if we later switch to Cinc binaries that are native arm.</p>
<h2 id="xcode-troubles">Xcode Troubles</h2>
<p>I experimented with using the “Release Candidate” of Xcode 12.3 as the main Xcode version, but there’s currently <a href="https://twitter.com/steipete/status/1337738685282988032?s=21">a bug that prevents installing any non-bundled simulators</a> (we still support iOS 12 in our <a href="https://pspdfkit.com/pdf-sdk/ios/">iOS PDF SDK</a>), which caused Cinc to stop with an error). For now, we’re sticking with Xcode 12.2 in hopes that Apple fixes this soon. I assume this is a server-side error, so it shouldn’t be hard to fix.</p>
<p>There’s <a href="https://twitter.com/steipete/status/1336428545791434752">a promising fix in Xcode 12.3</a> for “improved responsiveness of macOS mouse and keyboard events while under heavy load, such as when building a large project while running Simulator,” and a fix for <a href="https://twitter.com/steipete/status/1332348616145563653">random lockups of the CoreSimulator service</a>, so I’m itching to upgrade as soon as possible.</p>
<p><strong>Update:</strong> Apple fixed this issue server-side; the list is loading now.</p>
<h2 id="test-troubles">Test Troubles</h2>
<p>Some features in our <a href="https://pspdfkit.com/pdf-sdk/ios/">iOS PDF SDK</a> use <code class="language-plaintext highlighter-rouge">WKWebView</code>, e.g. <a href="https://pspdfkit.com/pdf-sdk/reader-view/#ios">Reader View, which reflows PDFs so they’re easier to read on mobile devices</a>. These tests <a href="https://steipete.com/posts/apple-silicon-m1-a-developer-perspective/">crash with a memory allocator error on Big Sur</a>.</p>
<p>If you see <a href="https://gist.github.com/steipete/7181cf321d979d734c5acd2326f6c33f"><code class="language-plaintext highlighter-rouge">bmalloc::HeapConstants::HeapConstants</code></a> in a crash stack trace, that’s likely this bug. While my radar has no reply yet, I’ve heard this is a bug in Big Sur and requires an OS update to be fixed, so this will potentially be resolved in 10.2 some time in Q1 2021.</p>
<p>I’ve currently worked around this by detecting Rosetta at runtime and then skipping any tests that call into WebKit by using this snippet to detect execution state:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</pre></td><td class="rouge-code"><pre><span class="k">let</span> <span class="nv">NATIVE_EXECUTION</span> <span class="o">=</span> <span class="kt">Int32</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">EMULATED_EXECUTION</span> <span class="o">=</span> <span class="kt">Int32</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">UNKNOWN_EXECUTION</span> <span class="o">=</span> <span class="o">-</span><span class="kt">Int32</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
<span class="c1">/// Test if the process runs natively or under Rosetta</span>
<span class="c1">/// https://developer.apple.com/forums/thread/652667?answerId=618217022&page=1#622923022</span>
<span class="kd">private</span> <span class="kd">func</span> <span class="nf">processIsTranslated</span><span class="p">()</span> <span class="o">-></span> <span class="kt">Int32</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">key</span> <span class="o">=</span> <span class="s">"sysctl.proc_translated"</span>
<span class="k">var</span> <span class="nv">ret</span> <span class="o">=</span> <span class="kt">Int32</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
<span class="k">var</span> <span class="nv">size</span><span class="p">:</span> <span class="kt">Int</span> <span class="o">=</span> <span class="mi">0</span>
<span class="nf">sysctlbyname</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="kc">nil</span><span class="p">,</span> <span class="o">&</span><span class="n">size</span><span class="p">,</span> <span class="kc">nil</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
<span class="k">let</span> <span class="nv">result</span> <span class="o">=</span> <span class="nf">sysctlbyname</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="o">&</span><span class="n">ret</span><span class="p">,</span> <span class="o">&</span><span class="n">size</span><span class="p">,</span> <span class="kc">nil</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
<span class="k">if</span> <span class="n">result</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span> <span class="p">{</span>
<span class="k">if</span> <span class="n">errno</span> <span class="o">==</span> <span class="kt">ENOENT</span> <span class="p">{</span>
<span class="k">return</span> <span class="mi">0</span>
<span class="p">}</span>
<span class="k">return</span> <span class="o">-</span><span class="mi">1</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">ret</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="memory-is-tight">Memory Is Tight</h2>
<p>We’ve been running six parallel instances of our tests (one per core) on Intel via the <code class="language-plaintext highlighter-rouge">-parallel-testing-worker-count</code> option. To make things work well for the M1 chip, I reduced the workload to four instances. There are really only four fast cores and four low-power cores, the latter of which perform badly and cause various issues with timeouts in tests. The machine also starts swapping too much memory, as the 16 GB isn’t all that much. Reducing the number to four seems to be the best solution to both more predictable and faster tests.</p>
<h2 id="breaking-out-of-rosetta">Breaking Out of Rosetta</h2>
<p>Even though our Buildkite agent runs natively, I missed that Cinc installs an Intel version of Ruby (via <code class="language-plaintext highlighter-rouge">asdf</code>), and we use Ruby to script CI and parse test results. The Intel Ruby kicks off <code class="language-plaintext highlighter-rouge">xcodebuild</code>, which then also runs in emulation mode because we’re already in an emulation context.</p>
<p>I’ve tried switching to ARM-based Ruby. The latest version, 2.7.2, does support compiling to ARM, but there are still many gems that use native dependencies that haven’t been updated yet. Realistically, it’ll take a while before we can switch to native Ruby there.</p>
<p>Luckily, there’s a way to break out: I’ve been prefixing the <code class="language-plaintext highlighter-rouge">xcodebuild</code> command with <code class="language-plaintext highlighter-rouge">arch -arm64e</code> to enforce the native context. This is currently hardcoded in a branch, and I’ll use a similar trick to detect the native architecture as in the Ruby script above. Sadly, there’s no <code class="language-plaintext highlighter-rouge">arch -native</code> command that would do this for us.</p>
<p>This is important! <a href="https://twitter.com/steipete/status/1338152854662549509?s=21">Performance is really terrible</a> if Clang runs in Intel-emulation mode.</p>
<h2 id="launchctl-weirdness">launchctl Weirdness</h2>
<p>I’ve encountered a few other weird issues. <code class="language-plaintext highlighter-rouge">launchctl</code> changed a bit in Big Sur and now throws “<a href="https://twitter.com/steipete/status/1338155208044638210?s=21">Bootstrap failed: 125: Unknown error: 125</a>” or “Load error 5: input/output error” if the service is already running. This again had no Google results, so it took some time to understand. Sometimes it would also write “Disk I/O error 5 or Load error 5: input/output error,” which caused me to request a complete reset of the machine with MacStadium, only to see the same error again many hours later.</p>
<p>In our case, the fix was to explicitly unload the Buildkite service before registering it again — this has only shown up since the automation script stopped halfway due to my various tweaks. It’s also important that you’re logged in as the user you’re registering the service for (via screen sharing).</p>
<h2 id="results">Results</h2>
<p><img src="/assets/img/2020/apple-silicon-ci/buildkite.png" alt="Buildkite Test Results" /></p>
<p>The M1 runs our tests around 10 percent faster on iOS 14. Tests on older versions of iOS are <a href="https://twitter.com/steipete/status/1338219014338850816?s=21">around 30 to 70 percent slower</a>, since the Simulator runs via Rosetta’s emulation mode. Results range from 7–9 minutes vs. 5 minutes on Intel.</p>
<p>I’ve also seen <a href="https://twitter.com/steipete/status/1338152854662549509?s=21">Rosetta bugs in the logs, which caused tests to fail</a>. Twitter birds tell me that Big Sur 11.1 comes with many fixes to Rosetta, so this seems like a transitionary issue.</p>
<p>The new machines are marginally cheaper to host ($129/month vs. $159/month on MacStadium), but they’re still only on limited availability (<a href="https://twitter.com/steipete/status/1337170464460988417?s=21">we only got two even though we ordered five</a>) and software is still experimental. There are currently more problems than benefits in updating your fleet to M1, especially if you need to support versions below iOS 14.</p>
<p><a href="https://twitter.com/steipete/status/1333809139190034433?s=21">My Twitter research</a> thread contains a few more details, along with a glimpse at various stages of frustration and delight. Follow me if you enjoy such stories.</p>
<p>PS: The header graphic isn’t broken; it’s a random VNC corruption, and I <a href="https://twitter.com/facethewolf/status/1337733279454294019?s=21">rolled with it</a>.</p>
<p><strong>Update:</strong> We decided to keep the M1s after all, since we found two valid bugs in our codebase that only happen on arm64. This is a valid reason to deal with the current difficulties in setup.</p>Peter SteinbergerEver since the M1 was announced, I’ve been curious how well Apple’s new Mac mini would perform for our CI system. A few days ago, we finally got access to two M1 Mac minis hosted on MacStadium (8-core M1, 16 GB unified memory, 1 TB SSD, 1 GbE).Apple Silicon M1: A Developer’s Perspective2020-11-28T14:00:00+01:002020-11-28T14:00:00+01:00https://steipete.com/posts/apple-silicon-m1-a-developer-perspective<p>The excitement around Apple’s new M1 chip is <a href="https://www.singhkays.com/blog/apple-silicon-m1-black-magic/">everywhere</a>. I bought a MacBook Air 16 GB M1 to see how viable it is as a main development machine — here’s an early report after a week of testing.</p>
<h2 id="xcode">Xcode</h2>
<p>Xcode runs FAST on the M1. Compiling the <a href="https://pspdfkit.com/">PSPDFKit PDF SDK</a> (debug, arm64) can almost compete with the fastest Intel-based MacBook Pro Apple offers (to date), with <a href="https://twitter.com/steipete/status/1332052251712614405?s=21">8:49 minutes vs. 7:31 minutes</a>. For comparison, my Hackintosh builds the same in less than 5 minutes.</p>
<p>One can’t overstate how impressive this is for a fanless machine. Apple’s last experiment with fanless MacBooks was the 12-inch version from 2017, which builds the same project in 41 minutes.</p>
<p>Our tests mostly ran just fine, although I found <a href="https://github.com/Aloshi/dukglue/pull/27">a bug specific to arm64</a>, which we missed before, as <a href="https://pspdfkit.com/blog/2020/managing-macos-hardware-virtualization-or-bare-metal/">we don’t run our tests on actual hardware</a> <a href="https://pspdfkit.com/blog/2020/continuous-integration-for-small-ios-macos-teams/">on CI</a>. Moving the simulator to the same architecture as shipping devices will be beneficial and will help find more bugs.</p>
<p>Testing iOS below 14 is problematic. It seems <a href="https://twitter.com/steipete/status/1332654247809257473?s=21">WebKit crashes in a memory allocator</a>, throwing <code class="language-plaintext highlighter-rouge">EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)</code> (Apple folks: FB8920323). Performance also seems really bad, with Xcode periodically <a href="https://twitter.com/steipete/status/1332348616145563653?s=21">freezing</a>, and the whole system becoming so <a href="https://twitter.com/steipete/status/1332648748158246922?s=21">slow</a> that the mouse cursor gets choppy. Some simulators even make problems on iOS 14; an example of this is <a href="https://twitter.com/steipete/status/1331628274783543297?s=21">iPad Air (4th generation), which still emulates Intel</a>, so try to avoid that one.</p>
<p>We were extremely excited to be moving our CI to Mac minis with the M1 chip and are <a href="https://www.macstadium.com/m1-mini">waiting on MacStadium to release devices</a>. However, it seems we’ll have to restrict tests to iOS 14 for that to work. With our current schedule, we plan to drop iOS 12 in Q3 2021 and iOS 13 in Q3 2022, so it’ll be a while before we can fully move to Apple Silicon.</p>
<p>There is a chance that Apple fixes these issues, but it’s not something to count on — given that this only affects older versions of iOS, the problem will at some point just “go away.”</p>
<p><strong>Update:</strong> We’re working around the WebKit crashes for now via detecting Rosetta 2 translation at runtime and simply skipping the tests where WebKit is used. This isn’t great, but luckily we’re not using WebKit a lot in our current project. <a href="https://gist.github.com/steipete/e15b1fabffc7da7d49c92e3fbd06971a">See my gist for details</a>. Performance seems acceptable if you restrict parallel testing to, at most, two instances — otherwise, the system simply runs out of RAM and swapping is really slow.</p>
<p><strong>Update 2:</strong> I’ve heard that the choppy mouse cursor is an Xcode/Simulator bug, and it’s currently being worked on. As a workaround, ensure at least one Simulator window is onscreen and visible.</p>
<p><strong>Update 3:</strong> Great news! The WebKit crash when running on Rosetta 2 will be resolved with a future update in Big Sur.</p>
<p><strong>Update 4 (May 2021):</strong> This is now fixed with Xcode 12.5 and macOS 11.3.</p>
<h2 id="docker">Docker</h2>
<p>We use Docker to automate our website and load environments for our <a href="https://pspdfkit.com/pdf-sdk/web/">Web and Server PDF SDKs</a>. Docker posted a <a href="https://www.docker.com/blog/apple-silicon-m1-chips-and-docker/">status update blog post</a> admitting that its client currently won’t work with Apple Silicon, but that the company is <a href="https://github.com/docker/roadmap/issues/142">working on it</a>. There are more <a href="https://finestructure.co/blog/2020/11/27/running-docker-on-apple-silicon-m1-follow-up">hacky ways to use Apple’s Hypervisor to run Docker containers manually</a>, but they need ARM-based containers.</p>
<p>I expect a solution that runs ARM-based containers in Q1 2021. We at PSPDFKit will have some work to do to add ARM support (something already on the roadmap), so this is only a transitional issue.</p>
<h2 id="virtualization-and-windows">Virtualization and Windows</h2>
<p>To test our <a href="https://pspdfkit.com/pdf-sdk/windows/">Windows PDF SDK</a>, most folks are using a VMware virtual machine with Windows 10 and Visual Studio. Currently, none of the Mac virtualization solutions support Apple Silicon. However, both <a href="https://appleinsider.com/articles/20/11/11/parallels-confirms-apple-m1-support-amid-silence-from-other-virtualization-companies">VMware and Parallels</a> are working on it. I don’t expect VirtualBox to be updated <a href="https://forums.virtualbox.org/viewtopic.php?f=8&t=98742">anytime soon</a>.</p>
<p>I expect that, eventually, we’ll be able to run ARM-based Windows with commercial tooling. Various <a href="https://9to5mac.com/2020/11/27/arm-windows-virtualization-m1-mac/">proofs of concept</a> already exist, and performance seems <a href="https://twitter.com/imbushuo/status/1332772957609922561?s=21">extremely promising</a>. Microsoft currently doesn’t sell ARM-based Windows, so getting a license will be interesting.</p>
<p>Windows 10 on ARM can emulate x86 applications, and Microsoft is working on <a href="https://www.neowin.net/news/it039s-official-x64-emulation-is-coming-to-windows-on-arm">x64 emulation</a>, which is already rolling out in Insider builds. In a few months, it should be possible to develop and test our Windows SDK with Visual Studio on M1 with reasonable performance results.</p>
<p>Running older versions of macOS might be more problematic. We currently support macOS 10.14 with our <a href="https://pspdfkit.com/blog/2017/pspdfkit-for-macos/">AppKit PDF SDK</a>, and macOS 10.15 with the <a href="https://pspdfkit.com/blog/2019/pspdfkit-for-mac-catalyst/">Catalyst PDF SDK</a>, both of which are OS releases that require testing. It remains to be seen if VMware or Parallels include a complete x64 emulation layer. This would likely be really slow, so I wouldn’t count on it.</p>
<p><img src="/assets/img/2020/m1/memory.png" alt="" /></p>
<p>Lastly, 16 GB RAM just isn’t a lot. When running parallel tests, the machine starts to heavily swap, and performance really goes down the drain. This will be even more problematic with virtual machines running. Future machines will offer 32 GB options to alleviate this issue.</p>
<p><strong>Update:</strong> Check out <a href="https://gist.github.com/niw/e4313b9c14e968764a52375da41b4278#file-readme-md">How to run Windows 10 on ARM in QEMU with Hypervisor.framework patches on Apple Silicon Mac</a>.</p>
<h2 id="android-studio">Android Studio</h2>
<p>IntelliJ is working on porting the <a href="https://youtrack.jetbrains.com/issue/JBR-2526">JetBrains Runtime</a> to Apple Silicon. JetBrains apps currently work through Rosetta 2; however, building via Gradle is <a href="https://www.reddit.com/r/androiddev/comments/jx4ntt/apple_macbook_air_m1_is_very_slow_in_gradle_builds/">extremely slow</a>. Gradle creates code at runtime, which seems like a particularly bad combination with the Rosetta 2 ahead-of-time translation logic.</p>
<p>I expect most issues will be solved by Q1 2021, but it’ll likely be some more time until all Java versions run great on ARM. A lot of effort has been put into <a href="https://bell-sw.com/java/arm/performance/2019/01/15/the-status-of-java-on-arm/">loop unrolling and vectorization</a>; not everything there is available on ARM just yet.</p>
<p><strong>Update:</strong> <a href="https://www.azul.com/press_release/azul-announces-support-of-java-builds-of-openjdk-for-apple-silicon/">Azul offers macOS JDKs for arm64</a> — also for <a href="https://www.azul.com/downloads/zulu-community/?os=macos&architecture=arm-64-bit&package=jdk">Java 8</a>.</p>
<h2 id="homebrew">Homebrew</h2>
<p><a href="https://brew.sh/">Homebrew</a> currently works via Rosetta 2. Prefix everything with <code class="language-plaintext highlighter-rouge">arch -x86_64</code> and it’ll just work. It’s possible to install an additional (ARM-based) version of Homebrew <a href="https://soffes.blog/homebrew-on-apple-silicon">under <code class="language-plaintext highlighter-rouge">/opt/homebrew</code></a> and mix the setup, as <a href="https://github.com/Homebrew/brew/issues/7857">more and more software</a> is adding support for ARM.</p>
<p>This isn’t currently a problem (performance is good) and will eventually just work natively.</p>
<h2 id="applications">Applications</h2>
<p>Most applications just work; Rosetta is barely noticeable. Larger apps take a longer initial performance hit (e.g. Microsoft Word takes <a href="https://www.zdnet.com/article/microsoft-office-will-be-about-20-second-slower-initially-on-apple-silicon-rosetta-2/">around 20 seconds</a> until everything is translated), but then the binaries are cached and subsequent runs are fast.</p>
<p>There’s the occasional app that can’t be translated and fails on startup (e.g. <a href="https://beamer-app.com/download">Beamer</a> and the <a href="https://www.google.com/intl/en_gh/drive/download/">Google Drive Backup and Sync client</a>), but this is rare. Some apps are confused about their place on disk and ask to be moved to the Applications directory, when really it’s just the translated binary that runs somewhere else. Most of these dialogs can be ignored. Some apps (e.g. Visual Studio Code) <a href="https://twitter.com/steipete/status/1331884524934995968?s=21">block auto updating</a>, as the translated app location is read-only. However, in the case of VS Code, the Insider build is already updated to ARM and just works.</p>
<p>Electron-based apps are slow if they run on Rosetta. It seems the highly optimized V8 JavaScript compiler blocks ahead-of-time translation. The latest stable version of Electron (version 11) already <a href="https://www.electronjs.org/blog/apple-silicon">fully supports Apple Silicon</a>, and some companies — including Slack and 1Password — have updated their beta versions to run natively.</p>
<p>Google just shipped <a href="https://www.macworld.com/article/3597749/google-releases-chrome-87-with-support-for-apple-silicon-macs.html">Chrome that runs on ARM</a>, but there’s still quite a big performance gap between it and Apple Safari, which just <em>flies</em> on Apple Silicon.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The new M1 MacBooks are fast, beautiful, and silent, and the hype is absolutely justified. There’s still a lot to do on the software front to catch up, and the bugs around older iOS simulators are especially problematic.</p>
<p>All of that can be fixed in software, and the entire industry is currently working on making the experience better, so by next year — when Apple updates the 16-inch MacBook Pro and releases the next generation of its M chip line — it should be absolutely possible to use an M1 Mac as the main dev machine.</p>
<p>For the time being, the M1 will be my <del>travel</del> secondary laptop, and I’ll keep working on the 2,4 GHz 16-inch MacBook Pro with 32 GB RAM, which is just the faster machine. It’ll be much harder to accept the loud, always-on fans though, now that I know what soon will be possible.</p>Peter SteinbergerThe excitement around Apple’s new M1 chip is everywhere. I bought a MacBook Air 16 GB M1 to see how viable it is as a main development machine — here’s an early report after a week of testing.Gardening Your Twitter: Curating Your Timeline2020-10-21T16:00:00+02:002020-10-21T16:00:00+02:00https://steipete.com/posts/curating-your-twitter-timeline<p>Your timeline defines your Twitter experience. Learn strategies how to pick your followers, how to hide what’s not interesting and how to mute negative people and keep Twitter fun for you. This is the second part of my Twitter series about Gardening Your Twitter.</p>
<p>If you haven’t read part one yet, where I explain <a href="/posts/growing-your-twitter-followers">how you can grow your followers</a>.</p>
<h2 id="who-to-follow">Who to Follow</h2>
<p>The beauty of Twitter is that it’s a unique experience for everyone — you can pick topics that interest you by choosing whom to follow.</p>
<p>Be picky whose comments you want to read and whose thoughts you want to consume daily. I try to avoid folks who are too negative or who try to impress with “inspirational quotes,” as well as people whose signal/noise ratio is too high.</p>
<p>What do I mean with signal/noise ratio? I follow folks so I can learn more about certain topics. Some I know already, some I met at conferences, and some I’d love to meet eventually. Of course, you also want to share bits of yourself on your timeline, but if you only talk about the weather, food, or your kids, it’s probably not a fit for me — and that’s okay! Everyone can use Twitter as they see fit, and it’s impossible to follow everyone anyhow, so choose whatever content you prefer.</p>
<p>As I recently <a href="https://twitter.com/ndbroadbent/status/1317522304008556545?s=21">learned</a>, there’s an upper limit of 5,000 people you can follow on Twitter. I’d suggest to really try to stay below 1,000, as everything else is not manageable. I’m currently at 1,500, but I also follow many accounts that have stopped tweeting over the years or are extremely low in volume.</p>
<p>I try to read most of what’s on my timeline but I gave up being a completionist. Twitter needs time and commitment, but it shouldn’t feel like work.</p>
<p>I know that some people can take it personally if you unfollow, but I’m past caring about that. You need to filter what you consume, so unfollow when you don’t enjoy someone’s content.</p>
<p>Additionally, don’t worry about missing out — Twitter is a stream and it isn’t possible to read everything.</p>
<h2 id="mute-early-mute-often">Mute Early, Mute Often</h2>
<p>Muting accounts is <em>beautiful</em> — people can still see your content, but you no longer see their replies. In the early days, it took a lot to make me mute someone, but now I approach this feature differently.</p>
<p>I mute pretty much any account I don’t find interesting. This includes basically all brands and almost all accounts that post ads or anything related to sport events. <strong>There is no limit to how many accounts you can mute.</strong></p>
<p>Muting is useful because it hides content that other folks retweet without having to disable their retweets altogether. It’s also great to simply hide folks you don’t want to interact with. They can still see your content; you just won’t read their (snarky) replies.</p>
<p>Mute early, mute often. It’s your experience after all. If you miss a reply and someone really wants to reach you, they’ll find your email.</p>
<h2 id="muting-keywords">Muting Keywords</h2>
<p>You can <strong>mute up to 200 keywords</strong> or even partial sentences; it doesn’t need to be a hashtag. Here’s a selection of the things I mute:</p>
<ul>
<li>#MyTwitterAnniversary (I really don’t care about these)</li>
<li>constitution, police, assault, shooting, killing, #VOTE (probably something American that will just make me upset)</li>
<li>#digital, #offer, #BusinessTransformation, #sponsored, #blockchain, #bigdata (if you use any of these as a keyword, the content will be crap)</li>
<li>#NowPlaying (I’m not here for music; I follow people on SoundCloud for that)</li>
<li>NASCAR, Lakers, #F1, Strava, #CloseYourRings (not interested in sports or where you run or bike)</li>
<li>#Covid_19 (I read news about the pandemic when I feel like it. I don’t need that every five minutes.)</li>
<li>[food]/ (I already like food too much; don’t tempt me Frodo!)</li>
<li>#instagram (Keep your networks separate. If I wanna see selfies of you, I’ll follow you there.)</li>
</ul>
<h2 id="blocking">Blocking</h2>
<p>Blocking always feels weird. It’s something I rarely do. It’s more a <em>childish</em> act, since you can’t hide content that’s public — you’re just making it less convenient for the person to read your tweets.</p>
<p>There have been cases where someone replies to a RT of mine with mean comments, and since that shows me as connection to hateful or insulting comments, I quickly block those people. If it’s just a challenging reply, I usually don’t block — it’s fun to be challenged! (And there’s always mute if you stop enjoying it.)</p>
<h2 id="disabling-retweets">Disabling Retweets</h2>
<p>Some people retweet a lot of content that I don’t find particularly interesting, so I disable retweets on their account from showing up on my timeline. I do this probably for 5–10 percent of the accounts I follow, and I know some people who have that on for 100 percent of the accounts they follow.</p>
<p>In turn, people disabling retweets has changed Twitter’s behavior. So if you want to be sure someone sees content, use a quote tweet. But don’t use that too often, as <a href="https://twitter.com/NeoNacho/status/1313595333159469056">it can also be annoying</a>.</p>
<p>Before disabling retweets completely, consider using mute to just filter out content that’s not interesting to you.</p>
<h2 id="on-using-hashtags">On Using Hashtags</h2>
<p><strong>Please don’t use hashtags.</strong> If I could write a filter that automatically blocks all tweets with more than one hashtag, I would absolutely use it.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>
<p>Hashtags are usually used by people who don’t know how Twitter works, or by companies that push ads that #use a #hashtag for #almost #every #word, and doing this is really unnecessary and annoying to read.</p>
<p>Don’t be a noob: Don’t use hashtags. Exception are for fun hashtags or conferences. #butwhatdoiknow</p>
<h2 id="twitter-clients">Twitter Clients</h2>
<p>I used Tweetbot for quite a while but eventually settled on using Twitter for iOS and Mac. Twitter started with the concept of an open API, but it eventually added more and more features that haven’t been added to the API, so while third-party apps really try, they can’t replicate the Twitter experience you get with the native client.</p>
<p><strong>Examples:</strong></p>
<ul>
<li>The Activity screen contains data that isn’t accessible via the Twitter API, so you can’t really see which tweets resonate with your audience and which don’t.</li>
<li>Twitter Polls are not visible in third-party clients.</li>
<li>Threads can be somewhat reconstructed via searches, but that’s a slower process and it fails if the client runs out of API quota or the thread is too large.</li>
<li>Tweets where replying is limited are displayed as regular tweets and just return an error when you try to reply. This is frustrating. The official Twitter client shows if you can reply.</li>
<li>The typing indicator on DMs that shows if someone is responding to your message is only available on the native client. In fact, Twitter disabled its Streaming API so DMs are pretty much unusable unless you use the official client.</li>
<li>Much more that isn’t relevant but noteworthy — bookmarks, data saver mode, 4K images…</li>
</ul>
<p>If you’re looking for a good third-party client anyway, try <a href="https://tapbots.com/tweetbot/">Tweetbot</a> or <a href="https://apps.apple.com/us/app/id1522043420">Aviary</a>.</p>
<h3 id="linear-timeline">Linear Timeline</h3>
<p>If you use the official Twitter app, it comes with a non-linear timeline by default, showing tweets with the most engagement (“top tweets”) at the beginning. This is <a href="https://www.theverge.com/2020/3/6/21167920/twitter-chronological-feed-how-to-ios-android-app-timeline">a setting you can control</a>, and while it’s controversial, I do like the non-linear feed.</p>
<h3 id="liked-tweets">Liked Tweets</h3>
<p>Twitter also has this habit of showing tweets that your followers liked. This can be fun, but usually it’s irritating, as it doesn’t fit into my content selection. You can disable this by using the top right arrow icon and selecting “Not interested in this Tweet.” If you do that a few times, Twitter will slowly stop presenting liked tweets.</p>
<h2 id="part-1-growing-your-followers">Part 1: Growing Your Followers</h2>
<p>In the first part of this series, you’ll learn how you define your online persona and increase your reach to <a href="/posts/growing-your-twitter-followers">grow your followers</a>.</p>
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>You can set up a regular expression in Tweetbot, however, the official Twitter client doesn’t support such filters. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>Peter SteinbergerYour timeline defines your Twitter experience. Learn strategies how to pick your followers, how to hide what’s not interesting and how to mute negative people and keep Twitter fun for you. This is the second part of my Twitter series about Gardening Your Twitter.Gardening Your Twitter: Growing Your Followers2020-10-21T15:00:00+02:002020-10-21T15:00:00+02:00https://steipete.com/posts/growing-your-twitter-followers<p>I’ve been using Twitter for almost 12 years now. It can be challenging to navigate your timeline, so today I’m sharing some tips to keep it fun.</p>
<p>This is the first part of my Twitter series about Gardening Your Twitter. Don’t miss out on the second part, where you can learn how to best <a href="/posts/curating-your-twitter-timeline/">curate your timeline</a> and manage who to follow and unfollow.</p>
<h2 id="your-online-persona">Your Online Persona</h2>
<p>There are many strategies for online personas, but I can only share what works well for me. I follow people to cover specific areas/topics and also for their commentary/personality. In a way, Twitter is a newsfeed where the comments are presented before the content, and you pick people for both content and comments.</p>
<p>I’m known for talking about iOS and bootstrapping a company, and I have a pretty sharp tongue on tech news. I used to keep politics out of my feed, but since 2016, I do sprinkle in topics that are important to me — from US politics to climate change and LGBTQIA rights.</p>
<p>There will always be people who complain that XY topic shouldn’t be on Twitter, but in the end, it’s <em>your choice</em> what you talk about and it’s their choice to follow you.</p>
<p>I’m openly gay on Twitter, but only in the last few years have I also started talking about that. Being open does allow me to add a unique perspective to some content, and it adds more complexity to my persona. I almost never share pictures or private content though; <a href="https://www.instagram.com/sportg33k/">that stuff is for Instagram</a>.</p>
<p>Whatever you go with, be authentic. I don’t share everything on Twitter, but what I do share is honest and is usually done with passion. Additionally, it can be interesting or funny. I do not share content for money or for favors, rather I only share things if I find them interesting.</p>
<h3 id="your-avatar">Your Avatar</h3>
<p>Pick an avatar you like and stick with it. I recommend a real face and not a sketch or something more abstract, as it’ll help folks identify you at conferences or events. Make sure you use the same picture and use it everywhere (GitHub, Gravatar, email, etc.) so that you have one universal online identity. People will scan the picture much faster than your name — changing it is usually something folks dislike, and it’ll result in a temporary loss of engagement. You can change it, but I recommend not doing that, or at least doing so only every few years.</p>
<p>Or, you can be really sneaky and just <a href="https://krausefx.com/blog/continuous-delivery-for-your-profile-picture">remake your picture so it changes slightly every year</a>.</p>
<h3 id="direct-messages">Direct Messages</h3>
<p>I highly recommend going into Settings and privacy > Privacy and safety > Direct Messages and enabling “Receive messages from anyone.” There’s a lot of great commentary from people that I received via DM since they’re not comfortable replying publicly. There are the occasional odd messages (and inappropriate offers), but if you’re a cis white male, you likely are good. Minority groups might want to reconsider this setting or at least enable the Quality filter.</p>
<p><img src="/assets/img/2020/make-twitter-work/settings.png" alt="Twitter Settings" /></p>
<p>If in doubt, I suggest you experiment with this — the settings are easy to change if it turns out to be a bad idea.</p>
<h3 id="multiple-profiles">Multiple Profiles</h3>
<p>Quite a few of my friends have “alt” accounts for the hot takes or for talking with friends. If you work at a Big Corp, you might be required to filter what you say, and having an alt can be a solution. In general, I don’t recommend making an alt account, as it’s simply too much work to maintain multiple accounts. Just tweet out your hot takes and attract the right followers on your main account.</p>
<h2 id="extending-reach">Extending Reach</h2>
<p>The more active followers you have on Twitter, the more fun it becomes. There’s no hack or shortcut for gaining followers, but there are various things you can do that can help you steadily grow your audience.</p>
<h3 id="blog-posts">Blog Posts</h3>
<p>Twitter is a great indicator for topics that people find interesting — <a href="https://twitter.com/steipete/status/1297956386836566016">I often get my best ideas for blog posts out of Twitter conversations</a>, and I also already have half the content there. Twitter is great for inspiration and to learn, but it’s often hard to read and follow conversations. Go the extra mile and convert some of these interactions to blog posts. This will greatly extend your reach, and in turn, it’ll attract new followers who find your content interesting.</p>
<h3 id="conference-talks">Conference Talks</h3>
<p><a href="https://steipete.tv/">Speaking at conferences</a> is a great way to meet new people and extend your social circle. I often meet folks at conferences, and either we connect on Twitter or we find out that we already know each other on there! Either way — this will increase the bond and will make it more likely that people reach out to you. <strong>Conferences are work, but they are so worth it.</strong></p>
<p>Bonus: <a href="https://pspdfkit.com/blog/2018/binary-frameworks-swift/">Convert your conference talk to a blog post</a>. Very few people will actually watch a recording, so via recycling and reshaping content you already have, you can extend your reach again.</p>
<p>If you plan on starting to speak, create a website where you list what topics you can talk about and your bio. I’m using <a href="https://github.com/steipete/speaking">a simple GitHub repo</a> that has been proven extremely useful for me to track past events, attract new speaking gigs, and help conference organizers with getting the information they need to announce me.</p>
<h3 id="engage-with-your-audience">Engage with Your Audience</h3>
<p>I try to reply to almost everyone who interacts with me on Twitter. This doesn’t take much time, and sometimes I just reply with an emoji, but taking time to engage shows your audience you care, and they’re much more likely to interact with your content again if they know that it’s not a one-way street. Same goes for your feed — don’t just read, reply. This can range from helping others with questions/problems to just posting a “me too” retweet. Sometimes I get content in my feed via a retweet, and by interacting with that, I get a new follower.</p>
<h3 id="tracking-statistics">Tracking Statistics</h3>
<p>Be consistent. You won’t grow an audience overnight. Make Twitter a daily thing. Share content. Be present — and you’ll grow your audience every day.</p>
<p><a href="https://analytics.twitter.com/">Twitter Analytics</a> is great to understand which tweets work. To track long-term performance, I’m using <a href="http://birdbrainapp.com/">Birdbrain</a>. It’s one of the oldest apps on my phone, so I have data since 2014. Interestingly, my follower count has been growing pretty much linearly:</p>
<p><img src="/assets/img/2020/make-twitter-work/follower.png" alt="Birdbrain Follower Count of @steipete" width="50%" /></p>
<h2 id="tweets-that-work">Tweets that Work</h2>
<p>I do share a lot of news articles. I often just quote something interesting from the news if it doesn’t need strong commentary, but the inclusion of a pull quote helps show that it’s worth reading.</p>
<p>The tweets that are the most engaging, however, usually are original content, particularly in context with your audience and topics of interest. Here are some of my top performing tweets from the last few months, with about 80K–450K impressions each. Sometimes it’s the <a href="https://twitter.com/steipete/status/1310331623729229827">ridiculous tweets that explode</a>, and sometimes you <a href="https://twitter.com/steipete/status/1306884214252613632?s=20">don’t need words</a>. It also can be news commentary if the comment <a href="https://twitter.com/steipete/status/1288151223028322304">really nails it</a> or just <a href="https://twitter.com/steipete/status/1281547449660825601">really fits</a>.</p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Me (red) vs people knowing Swift <a href="https://t.co/NBOX95Nza2">pic.twitter.com/NBOX95Nza2</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1313864628967964672?ref_src=twsrc%5Etfw">October 7, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Writing UIKit in Objective-C after being in SwiftUI-Land. <a href="https://t.co/4TpsPlkxVC">pic.twitter.com/4TpsPlkxVC</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1317061856901570560?ref_src=twsrc%5Etfw">October 16, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<h3 id="using-threads">Using Threads</h3>
<p>Lately I’ve been using more and more threads to connect tweets over time — this has been proven to be really great, as it immediately gives people context, they can read more, and the official Twitter client also usually shows 2–3 tweets in a thread, giving you more “space” in the timeline. Here’s an example:</p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Been clicking around for a minute with Apple's new Fruta SwiftUI sample. <br /><br />Things jump around wildly, fav' doesn't work, and it crashes once you open a second window. I understand it's b1, but looking at how SwiftUI went last year I doubt this will all be fixed. <a href="https://t.co/zGRRYswRde">pic.twitter.com/zGRRYswRde</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1277623561604214784?ref_src=twsrc%5Etfw">June 29, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<h2 id="curating-your-timeline">Curating Your Timeline</h2>
<p>Who you follow defines your Twitter experience. Learn how you can curate your Twitter timeline to keep it fun and interesting by reading <a href="/posts/curating-your-twitter-timeline/">the second part of this series</a>.</p>
<h2 id="addendum-building-personal-brands-for-introverts">Addendum: Building Personal Brands for Introverts</h2>
<p>I gave a talk at UIKonf in Berlin in 2018 about Building Personal Brands for Introverts. This talk is still highly relevant and goes even deeper into defining your online identity. Check it out if you want to know more.</p>
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/0c6izSzP-KQ" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>Peter SteinbergerI’ve been using Twitter for almost 12 years now. It can be challenging to navigate your timeline, so today I’m sharing some tips to keep it fun.Forbidden Controls in Catalyst: Optimize Interface for Mac2020-09-22T20:00:00+02:002020-09-22T20:00:00+02:00https://steipete.com/posts/forbidden-controls-in-catalyst-mac-idiom<style type="text/css">
div.post-content > img:first-child { display:none; }
</style>
<p>While working on our <a href="https://pdfviewer.io">PDF Viewer</a> update for Big Sur and switching to the new Catalyst Mac Interface Idiom, I was greeted with a new exception coming directly from UIKit:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>[General] UIStepper is not supported when running Catalyst apps in the Mac idiom.
[General] (
0 CoreFoundation 0x00007fff2067fbdf __exceptionPreprocess + 242
1 libobjc.A.dylib 0x00007fff2029f469 objc_exception_throw + 48
2 UIKitCore 0x00007fff464af1e5 -[UIView(UICatalystMacIdiomUnsupported_Internal) _throwForUnsupportedNonMacIdiomBehaviorWithReason:] + 0
3 UIKitCore 0x00007fff45c7b5b6 -[UIStepper _didMoveFromWindow:toWindow:] + 218
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="catalyst-mac-idiom">Catalyst Mac Idiom</h2>
<p>Let’s take a step back — what’s the Catalyst Mac Idiom? With macOS 11 Big Sur, Catalyst learned a new presentation mode. Next to the classic mode where Catalyst apps are scaled to 77 percent and retain their iPad look, there’s a <strong>new Optimize Interface for Mac mode</strong> that doesn’t use scaling and replaces various UIKit controls with AppKit counterparts.</p>
<p><img src="/assets/img/2020/mac-idiom-forbidden-controls/mac-idiom-selector.png" alt="Xcode Selector for Catalyst Idiom" /></p>
<p>The new mode is available with Big Sur, and apps can be built so that they use scaling on Catalina and the new Mac mode on Big Sur. We’ll be releasing a new version of <a href="https://pdfviewer.io">PDF Viewer for Mac</a> using the new optimized mode as soon as Apple finalizes Big Sur.</p>
<p>If we write code that works on both Catalina and Big Sur, a category like this will be useful:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre><span class="kd">extension</span> <span class="kt">UIDevice</span> <span class="p">{</span>
<span class="c1">/// Checks if we run in Mac Catalyst Optimized For Mac Idiom</span>
<span class="k">var</span> <span class="nv">isCatalystMacIdiom</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
<span class="k">if</span> <span class="kd">#available(iOS 14, *)</span> <span class="p">{</span>
<span class="k">return</span> <span class="kt">UIDevice</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">userInterfaceIdiom</span> <span class="o">==</span> <span class="o">.</span><span class="n">mac</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="k">return</span> <span class="kc">false</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>This can later be used, for example, in SwiftUI (SwiftUI’s <code class="language-plaintext highlighter-rouge">Stepper</code> maps to <code class="language-plaintext highlighter-rouge">UIStepper</code>, which is disallowed):</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="c1">// UIStepper is not allowed for Catalyst Mac Idiom.</span>
<span class="k">if</span> <span class="o">!</span><span class="kt">UIDevice</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">isCatalystMacIdiom</span> <span class="p">{</span>
<span class="kt">Stepper</span><span class="p">(</span><span class="s">"Current Page: </span><span class="se">\(</span><span class="n">pageIndex</span> <span class="o">+</span> <span class="mi">1</span><span class="se">)</span><span class="s">"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="err">$</span><span class="n">pageIndex</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="mi">0</span><span class="o">...</span><span class="n">document</span><span class="o">.</span><span class="n">pageCount</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span>
<span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="appkit-in-uikit">AppKit In UIKit</h2>
<p>Internally, Apple uses a private <code class="language-plaintext highlighter-rouge">_UINSView</code> class to host an actual <code class="language-plaintext highlighter-rouge">NSView</code>. It’s too bad Apple didn’t consider making this class public API, which would’ve allowed us to freely mix AppKit with UIKit, but it’s a start.</p>
<p><img src="/assets/img/2020/mac-idiom-forbidden-controls/uinsview.png" alt="UIButton Mac Idiom" /></p>
<p>If we look into the runtime, the class does pretty much what we’d expect (I omitted some less useful methods for the sake of brevity):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>lldb) po [NSClassFromString(@"_UINSView") _shortMethodDescription]
<_UINSView: 0x7fff86fac238>:
in _UINSView:
Properties:
@property (readonly) struct CGSize _intrinsicFrameSize;
@property (readonly) NSView* contentNSView; (@synthesize contentNSView = _contentNSView;)
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="forbidden-controls">Forbidden Controls</h2>
<p>Back to our crash — things make a bit more sense now. There’s no great equivalent for <code class="language-plaintext highlighter-rouge">UIStepper</code> in AppKit, so the folks at Apple decided it’s better to throw an exception if this control is used (FB8727188).</p>
<p>The problem: It isn’t documented which controls are disallowed, and what’s even more problematic is some controls are allowed, but customizations are disallowed. What does <code class="language-plaintext highlighter-rouge">UISlider</code> map toward? We can get the pointer from the visual debugger and then use our knowledge of the class structure to call directly into the AppKit view:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>(lldb) po [0x7f9503c488f0 contentNSView]
<NSSlider: 0x7f9503c37a40>
</pre></td></tr></tbody></table></code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">NSSlider</code> is the obvious choice, however, the Mac version lacks the appearance customization options UIKit has. Calling any of these customization methods will simply throw (crash) at runtime:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>setMinimumTrackImage:forState: is not supported on PSPDFBrightnessSlider when running Catalyst apps in the Mac idiom.
(
0 CoreFoundation 0x00007fff2067fbdf __exceptionPreprocess + 242
1 libobjc.A.dylib 0x00007fff2029f469 objc_exception_throw + 48
2 UIKitCore 0x00007fff464af1e5 -[UIView(UICatalystMacIdiomUnsupported_Internal) _throwForUnsupportedNonMacIdiomBehaviorWithReason:] + 0
3 UIKitCore 0x00007fff4582af70 -[UISlider setMinimumTrackImage:forState:] + 197
</pre></td></tr></tbody></table></code></pre></div></div>
<p>This is problematic, as it’s yet another restriction that isn’t documented. A better choice would’ve been to keep the UIKit variant in case customization exceeds what AppKit can do. It’s a surprising late change, and folks working on Catalyst apps are frustrated:</p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">I have had to remove UISlider, UIStepper & UIRrefreshControl. Might as well start re-writing my entire app in AppKit while I’m at it.</p>— Kunal Sood (@_ImagineThis) <a href="https://twitter.com/_ImagineThis/status/1308412481375801344?ref_src=twsrc%5Etfw">September 22, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<h2 id="finding-whats-forbidden">Finding What’s Forbidden</h2>
<p>Since there’s no documentation and there are no release notes about any of these behaviors, that just leaves Hopper and decompiling UIKitCore.</p>
<p>We find references to following controls:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">UIStepper</code> (styling properties such as <code class="language-plaintext highlighter-rouge">setMinimumTrackImage:forState:</code>)</li>
<li><code class="language-plaintext highlighter-rouge">UIPickerView</code></li>
<li><code class="language-plaintext highlighter-rouge">UIRefreshControl</code></li>
<li><code class="language-plaintext highlighter-rouge">UISwitch</code> (inside <code class="language-plaintext highlighter-rouge">setTitle:</code>)</li>
<li><code class="language-plaintext highlighter-rouge">UIButton</code></li>
</ul>
<p>This includes any <em>subclasses</em> like <code class="language-plaintext highlighter-rouge">AVScrubber</code> (part of <code class="language-plaintext highlighter-rouge">AVPlayerView</code>), so <a href="https://twitter.com/dezinezync/status/1309053206597697536?s=21">this can become a real problem</a>.</p>
<p>The main method throwing is <code class="language-plaintext highlighter-rouge">_throwForUnsupportedNonMacIdiomBehaviorWithReason</code>, so it makes sense to search for it. It checks if the bundle identifier starts with “com.apple”, and if it does, it just logs an error, while all other apps get an exception. There’s yet another check for <code class="language-plaintext highlighter-rouge">_allowsUnsupportedMacIdiomBehavior</code>, which is interesting. It seems the above controls at least have partial support in Big Sur. This can be enabled via calling <code class="language-plaintext highlighter-rouge">_setAllowsUnsupportedMacIdiomBehavior:]</code> on them. And indeed, calling <code class="language-plaintext highlighter-rouge">[UIStepper _setAllowsUnsupportedMacIdiomBehavior:1];</code> (I’m using Objective-C here since it’s easier to just redeclare the method in the header) does result in a working app.</p>
<p><img src="/assets/img/2020/mac-idiom-forbidden-controls/hacked-uistepper.png" alt="UIStepper on macOS via a hack" /></p>
<p>There’s a stepper (next to the “Current Page: 2” label), however, clicking it doesn’t work. This is obviously not something we should ever use for shipping, but it’s fun to play with the internals!</p>
<h2 id="digging-deeper">Digging Deeper</h2>
<p>However most of the throw calls seem to be missing. I’ve been looking at the iOS version this whole time, when clearly this is conditional code and we need to look at the macOS version instead.</p>
<p>iOS Path: <code class="language-plaintext highlighter-rouge">/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework</code><sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>
<p>macOS Path: <code class="language-plaintext highlighter-rouge">/System/iOSSupport/System/Library/PrivateFrameworks/UIKitCore.framework</code></p>
<p>However, when we open the framework on macOS, there’s no binary. WTH? I remembered reading that Big Sur ships with <a href="https://mjtsai.com/blog/2020/06/26/reverse-engineering-macos-11-0/">a built-in dynamic linker cache of all system-provided libraries</a>. Luckily, <a href="https://www.hopperapp.com/">Hopper</a> has already been updated to be informed of that shared cache, so we can load it via <code class="language-plaintext highlighter-rouge">/System/Library/dyld/</code> and then select <code class="language-plaintext highlighter-rouge">UIKitCore</code> as the target.</p>
<p>Wait until everything is loaded and then select File > Produce Pseudo-Code File for All Procedures. This might take a few hours. Once the file is generated, pick a <em>fast</em> text editor (my weapon of choice for something like this is Sublime Text) and load the file. There, search for <code class="language-plaintext highlighter-rouge">_throwForUnsupportedNonMacIdiomBehaviorWithReason:</code> again.</p>
<p>The problem: The file is heavily obfuscated; Hopper can’t read the selector names. We can search for the string “Unsupported iOS or Mac Catalyst iPad Idiom” to find the selector matching <code class="language-plaintext highlighter-rouge">_throwForUnsupportedNonMacIdiomBehaviorWithReason:</code>. In my case, that’s <code class="language-plaintext highlighter-rouge">sub_7fff465801e5</code>.</p>
<h2 id="conclusion">Conclusion</h2>
<p>However, that’s the end of the story for now. The data is there, but the tools can’t <a href="https://twitter.com/bsr43/status/1308462962680659971?s=21">yet (!)</a> get a useful format out. We know there are at least five controls that throw an exception on <em>some</em> usage at runtime, however, which one it is exactly is currently hard to know. Shipping a Catalyst app in the new Mac idiom is definitely an adventure.</p>
<h2 id="decompile-via-lldb">Decompile via LLDB</h2>
<p>Jeff Johnson points out that one can <a href="https://lapcatsoftware.com/articles/bigsur3.html">abuse LLDB to decompile methods individually</a>. That approach would take far too long to find all the calls that throw here, but it’s a start.</p>
<h2 id="decompile-via-dyld-shared-cache-big-sur">Decompile via dyld-shared-cache-big-sur</h2>
<p>The <a href="https://github.com/antons/dyld-shared-cache-big-sur">dyld-shared-cache-big-sur project</a> uses modifications to Apple’s dyld project to fix Objective-C information when extracting the dyld_shared_cache from macOS Big Sur to help Hopper generate readable pseudocode. (Thanks <a href="https://twitter.com/lclhrst/status/1308468526840152064?s=21">@lclhrst</a> for the hint!)</p>
<p>Using this project, we can extract the dyld cache into a folder:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>./dyld_shared_cache_util -extract ~/Developer/macOS\ Big\ Sur /System/Library/dyld/dyld_shared_cache_x86_64
</pre></td></tr></tbody></table></code></pre></div></div>
<p>And then we can decompile UIKitCore with selector names. This doesn’t resolve the individual selector calls, but it’s a step forward.</p>
<h2 id="update-apple-added-the-list-of-disallowed-controls-in-the-macos-big-sur-1101-release-notes">Update: Apple added the list of disallowed controls in the macOS Big Sur 11.0.1 Release Notes</h2>
<p>This answers which controls are affected. Curiously this currently is the only place, and there’s no mention in the headers or compile-time support to warn when these methods are used - but it’s better than no docs.</p>
<p><img src="/assets/img/2020/mac-idiom-forbidden-controls/apple-docs.png" alt="" /></p>
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:1" role="doc-endnote">
<p>In the early days, it was just UIKit. A few years ago, Apple created an internal framework called UIKitCore, which exports more APIs and can be used for internal apps. UIKit is the smaller API for external developers (us). <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>Peter SteinbergerDisabling Keyboard Avoidance in SwiftUI’s UIHostingController2020-09-21T22:00:00+02:002020-09-21T22:00:00+02:00https://steipete.com/posts/disabling-keyboard-avoidance-in-swiftui-uihostingcontroller<style type="text/css">
div.post-content > img:first-child { display:none; }
</style>
<p>While SwiftUI is still being <a href="/posts/state-of-swiftui/">cooked hot</a>, it’s already really useful and can replace many parts of your app. And with <code class="language-plaintext highlighter-rouge">UIHostingController</code>, it can easily be mixed with existing UIKit code. With iOS 14, the SwiftUI team at Apple added keyboard avoidance logic to the hosting controller, which can result in pretty ugly scrolling behavior.</p>
<h2 id="when-keyboard-avoidance-is-unwanted">When Keyboard Avoidance Is Unwanted</h2>
<p>When the keyboard is visible and <code class="language-plaintext highlighter-rouge">UIHostingController</code> doesn’t own the full screen, views try to move away from the keyboard. This has been <a href="https://developer.apple.com/forums/thread/658432">a frustrating bug for many</a>, and it’s especially bad if you <a href="https://noahgilmore.com/blog/swiftui-self-sizing-cells/">embed <code class="language-plaintext highlighter-rouge">UIHostingController</code> as table view cells</a> or collection view cells.<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">1</a></sup></p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">I can’t bring myself to release this horribly buggy experience to users (thanks SwiftUI).<br /><br />My faith that Apple fixes this is low and I’m feeling really let down that these keyboard-avoidance issues weren’t fixed in Beta.<br /><br />This is a big lesson learned for me. <a href="https://t.co/fpTLwKOrQu">pic.twitter.com/fpTLwKOrQu</a></p>— Samuel Coe (@thesamcoe) <a href="https://twitter.com/thesamcoe/status/1306350596715282434?ref_src=twsrc%5Etfw">September 16, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<p>While it seems that there are some <a href="https://twitter.com/zntfdr/status/1306913858263552001?s=21">weird workarounds if you use iOS 14.2</a>, this seems unreliable, and folks still need a solution for iOS 14.0.</p>
<h2 id="fixing-strategies">Fixing Strategies</h2>
<p>While I’ve not been directly affected by this issue, I was curious and tried to fix it a while back. My first attempt was adding <code class="language-plaintext highlighter-rouge">.ignoresSafeArea(.keyboard)</code> to the SwiftUI view, but this doesn’t seem to change anything.</p>
<p>When the official ways don’t work, there’s always the runtime, so I’ve been <a href="https://twitter.com/steipete/status/1306153060700426240?s=21">inspecting the view controller’s methods</a> and looking for something to poke at. As the class is written in Swift, there’s very little that’s exposed to the Objective-C runtime — only methods that are overridden from <code class="language-plaintext highlighter-rouge">UIViewController</code> or exposed with <code class="language-plaintext highlighter-rouge">@objc</code> will show up here. I could potentially use a Swift mirror to see the remaining functions, but changing them is difficult. There was nothing interesting, so I reported a r̶a̶d̶a̶r̶ Feedback<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> and that was it.</p>
<p>A few days later, I was reading <a href="https://defagos.github.io/swiftui_collection_intro/">Samuel Défago’s brilliant blog post about how he wrapped <code class="language-plaintext highlighter-rouge">UICollectionView</code> for Swift</a>. In part 3, he presents a fix to an issue with <code class="language-plaintext highlighter-rouge">safeAreaInsets</code> in <code class="language-plaintext highlighter-rouge">UIHostingController</code>. This is done by <a href="https://defagos.github.io/swiftui_collection_part3/">modifying the <em>view</em> class</a>. It motivated me to take a closer look at the view — maybe Apple was hiding the keyboard avoidance logic there?</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
</pre></td><td class="rouge-code"><pre>po SwiftUI._UIHostingView<KeyboardSwiftUIBug.ContentView>.self.perform("_shortMethodDescription")
▿ Optional<Unmanaged<AnyObject>>
▿ some : Unmanaged<AnyObject>
- _value : <_TtGC7SwiftUI14_UIHostingViewV18KeyboardSwiftUIBug11ContentView_: 0x7fff87103a28>:
in _TtGC7SwiftUI14_UIHostingViewV18KeyboardSwiftUIBug11ContentView_:
Properties:
@property (nonatomic, readonly) struct UIEdgeInsets safeAreaInsets;
@property (nonatomic, retain) UIColor* backgroundColor;
@property (nonatomic, readonly) unsigned long _axesForDerivingIntrinsicContentSizeFromLayoutSize;
@property (nonatomic, readonly) BOOL _layoutHeightDependsOnWidth;
Instance Methods:
- (void) dealloc; (0x7fff566e9320)
- (id) initWithCoder:(id)arg1; (0x7fff566e91e0)
- (id) backgroundColor; (0x7fff566eaab0)
- (void) setBackgroundColor:(id)arg1; (0x7fff566eab30)
- (id) initWithFrame:(struct CGRect)arg1; (0x7fff566ec710)
- (void) layoutSubviews; (0x7fff566ea040)
- (void) traitCollectionDidChange:(id)arg1; (0x7fff566ea630)
- (struct CGSize) sizeThatFits:(struct CGSize)arg1; (0x7fff566eadb0)
- (struct UIEdgeInsets) safeAreaInsets; (0x7fff566ea790)
- (void) didUpdateFocusInContext:(id)arg1 withAnimationCoordinator:(id)arg2; (0x7fff566ec230)
- (id) preferredFocusEnvironments; (0x7fff566ec150)
- (void) safeAreaInsetsDidChange; (0x7fff566ea6c0)
- (void) didMoveToSuperview; (0x7fff566e9df0)
- (void) _geometryChanged:(void*)arg1 forAncestor:(id)arg2; (0x7fff566e9ef0)
- (id) _childFocusRegionsInRect:(struct CGRect)arg1 inCoordinateSpace:(id)arg2; (0x7fff566ec080)
- (struct ?) _baselineOffsetsAtSize:(struct CGSize)arg1; (0x7fff566eacd0)
- (unsigned long) _axesForDerivingIntrinsicContentSizeFromLayoutSize; (0x7fff566eabd0)
- (void) contentSizeCategoryDidChange; (0x7fff566ea5c0)
- (void) keyboardWillShowWithNotification:(id)arg1; (0x7fff566ec4f0)
- (void) keyboardWillHideWithNotification:(id)arg1; (0x7fff566ec5a0)
- (void) externalEnvironmentDidChange; (0x7fff566edd40)
- (id) makeViewDebugData; (0x7fff566ec5f0)
- (void) _geometryChanges:(id)arg1 forAncestor:(id)arg2; (0x7fff566e9e20)
(UIView ...)
</pre></td></tr></tbody></table></code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">UIHostingView</code> looks very interesting indeed.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> Based on the design, it seems that at some point, Apple considered giving us both the hosting controller and the hosting view, but then opted for just the controller — it wouldn’t make sense to pack all that logic into the view if the controller was always planned.</p>
<p>Looking at the output, I see there are quite a few Swift methods that have been exposed to the Objective-C runtime. <code class="language-plaintext highlighter-rouge">keyboardWillShowWithNotification:</code> and <code class="language-plaintext highlighter-rouge">keyboardWillHideWithNotification:</code> look exactly like candidates to tweak. We’re really lucky here that the SwiftUI engineers didn’t use the block-based <code class="language-plaintext highlighter-rouge">NSNotification</code> API<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">4</a></sup>, but instead used the target/selector approach — which not only needs <code class="language-plaintext highlighter-rouge">@objc</code> annotations to work, but also opens the door for our shenanigans.</p>
<h2 id="subclassing-at-runtime">Subclassing at Runtime</h2>
<p>We want to replace the implementation of <code class="language-plaintext highlighter-rouge">keyboardWillShowWithNotification:</code> with an empty one. The classic solution here would be <a href="https://pspdfkit.com/blog/2019/swizzling-in-swift/">swizzling</a>, but that would modify <em>all</em> instances of <code class="language-plaintext highlighter-rouge">UIHostingController</code>, and we don’t know if the view class is used somewhere else. It might work, but it seems risky.</p>
<p>A better strategy is to modify only instances we control, and we can do that via dynamic subclassing. It’s my favorite way to modify behavior on a per-object basis. In fact, I wrote an entire Swift library called <a href="https://interposekit.com/">InterposeKit</a> to make this easy:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
</pre></td><td class="rouge-code"><pre><span class="kd">import</span> <span class="kt">SwiftUI</span>
<span class="kd">import</span> <span class="kt">InterposeKit</span>
<span class="kd">extension</span> <span class="kt">UIHostingController</span> <span class="p">{</span>
<span class="kd">convenience</span> <span class="kd">public</span> <span class="nf">init</span><span class="p">(</span><span class="nv">rootView</span><span class="p">:</span> <span class="kt">Content</span><span class="p">,</span> <span class="nv">ignoresKeyboard</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">)</span> <span class="p">{</span>
<span class="k">self</span><span class="o">.</span><span class="nf">init</span><span class="p">(</span><span class="nv">rootView</span><span class="p">:</span> <span class="n">rootView</span><span class="p">)</span>
<span class="k">if</span> <span class="n">ignoreKeyboard</span> <span class="p">{</span>
<span class="n">_</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="k">self</span><span class="o">.</span><span class="n">view</span><span class="o">.</span><span class="nf">hook</span><span class="p">(</span><span class="kt">NSSelectorFromString</span><span class="p">(</span><span class="s">"keyboardWillShowWithNotification:"</span><span class="p">))</span> <span class="p">{</span> <span class="p">(</span>
<span class="nv">store</span><span class="p">:</span> <span class="kt">TypedHook</span><span class="o"><</span><span class="kd">@convention</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="p">(</span><span class="kt">AnyObject</span><span class="p">,</span> <span class="kt">Selector</span><span class="p">,</span> <span class="kt">AnyObject</span><span class="p">)</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">,</span>
<span class="kd">@convention</span><span class="p">(</span><span class="n">block</span><span class="p">)</span> <span class="p">(</span><span class="kt">AnyObject</span><span class="p">,</span> <span class="kt">AnyObject</span><span class="p">)</span> <span class="o">-></span> <span class="kt">Void</span><span class="o">></span><span class="p">)</span> <span class="k">in</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="k">in</span> <span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Dynamic subclassing isn’t very tricky, but the challenge is to write it in a way where it fails gracefully if the private API we modify is changed. InterposeKit adds a lot of error handling next to a convenient API so that you make fewer mistakes and have a more stable app. It’ll throw an error if the selection no longer exists or has a different type than the one you expect.</p>
<p>We can achieve something similar using built-in methods:</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
</pre></td><td class="rouge-code"><pre><span class="kd">extension</span> <span class="kt">UIHostingController</span> <span class="p">{</span>
<span class="kd">convenience</span> <span class="kd">public</span> <span class="nf">init</span><span class="p">(</span><span class="nv">rootView</span><span class="p">:</span> <span class="kt">Content</span><span class="p">,</span> <span class="nv">ignoresKeyboard</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">)</span> <span class="p">{</span>
<span class="k">self</span><span class="o">.</span><span class="nf">init</span><span class="p">(</span><span class="nv">rootView</span><span class="p">:</span> <span class="n">rootView</span><span class="p">)</span>
<span class="k">if</span> <span class="n">ignoresKeyboard</span> <span class="p">{</span>
<span class="k">guard</span> <span class="k">let</span> <span class="nv">viewClass</span> <span class="o">=</span> <span class="nf">object_getClass</span><span class="p">(</span><span class="n">view</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="k">let</span> <span class="nv">viewSubclassName</span> <span class="o">=</span> <span class="kt">String</span><span class="p">(</span><span class="nv">cString</span><span class="p">:</span> <span class="nf">class_getName</span><span class="p">(</span><span class="n">viewClass</span><span class="p">))</span><span class="o">.</span><span class="nf">appending</span><span class="p">(</span><span class="s">"_IgnoresKeyboard"</span><span class="p">)</span>
<span class="k">if</span> <span class="k">let</span> <span class="nv">viewSubclass</span> <span class="o">=</span> <span class="kt">NSClassFromString</span><span class="p">(</span><span class="n">viewSubclassName</span><span class="p">)</span> <span class="p">{</span>
<span class="nf">object_setClass</span><span class="p">(</span><span class="n">view</span><span class="p">,</span> <span class="n">viewSubclass</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">else</span> <span class="p">{</span>
<span class="k">guard</span> <span class="k">let</span> <span class="nv">viewClassNameUtf8</span> <span class="o">=</span> <span class="p">(</span><span class="n">viewSubclassName</span> <span class="k">as</span> <span class="kt">NSString</span><span class="p">)</span><span class="o">.</span><span class="n">utf8String</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="k">guard</span> <span class="k">let</span> <span class="nv">viewSubclass</span> <span class="o">=</span> <span class="nf">objc_allocateClassPair</span><span class="p">(</span><span class="n">viewClass</span><span class="p">,</span> <span class="n">viewClassNameUtf8</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="k">if</span> <span class="k">let</span> <span class="nv">method</span> <span class="o">=</span> <span class="nf">class_getInstanceMethod</span><span class="p">(</span><span class="n">viewClass</span><span class="p">,</span> <span class="kt">NSSelectorFromString</span><span class="p">(</span><span class="s">"keyboardWillShowWithNotification:"</span><span class="p">))</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">keyboardWillShow</span><span class="p">:</span> <span class="kd">@convention</span><span class="p">(</span><span class="n">block</span><span class="p">)</span> <span class="p">(</span><span class="kt">AnyObject</span><span class="p">,</span> <span class="kt">AnyObject</span><span class="p">)</span> <span class="o">-></span> <span class="kt">Void</span> <span class="o">=</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="k">in</span> <span class="p">}</span>
<span class="nf">class_addMethod</span><span class="p">(</span><span class="n">viewSubclass</span><span class="p">,</span> <span class="kt">NSSelectorFromString</span><span class="p">(</span><span class="s">"keyboardWillShowWithNotification:"</span><span class="p">),</span>
<span class="nf">imp_implementationWithBlock</span><span class="p">(</span><span class="n">keyboardWillShow</span><span class="p">),</span> <span class="nf">method_getTypeEncoding</span><span class="p">(</span><span class="n">method</span><span class="p">))</span>
<span class="p">}</span>
<span class="nf">objc_registerClassPair</span><span class="p">(</span><span class="n">viewSubclass</span><span class="p">)</span>
<span class="nf">object_setClass</span><span class="p">(</span><span class="n">view</span><span class="p">,</span> <span class="n">viewSubclass</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>See <a href="https://gist.github.com/steipete/da72299613dcc91e8d729e48b4bb582c#file-uihostingcontroller-keyboard-swift">my gist</a> for a version that also removes <code class="language-plaintext highlighter-rouge">safeAreaInsets</code>. Who would’ve thought that runtime trickery is still useful in SwiftUI times?</p>
<p>If this is useful to you, ping <a href="https://twitter.com/steipete">@steipete on Twitter</a>!</p>
<div class="footnotes" role="doc-endnotes">
<ol>
<li id="fn:4" role="doc-endnote">
<p>Apple Folks: FB8698723 — Provide API in UIHostingController to disable keyboard avoidance for SwiftUI views. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>If you want to try this for yourself, <a href="https://twitter.com/steipete/status/1306925835010609152?s=21">I’ve prepared an example here</a>. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>The <code class="language-plaintext highlighter-rouge">makeViewDebugData</code> method also looks pretty interesting… <a href="#fnref:3" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
<li id="fn:1" role="doc-endnote">
<p>The block-based notification API nowadays is inconvenient, as it doesn’t automatically deregister observers — using the target/action one is simpler, as these observers have automatically deregistered since iOS 9. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
</li>
</ol>
</div>Peter SteinbergerThe State of SwiftUI2020-09-13T11:00:00+02:002020-09-13T11:00:00+02:00https://steipete.com/posts/state-of-swiftui<style type="text/css">
div.post-content > img:first-child { display:none; }
</style>
<p>Apple released SwiftUI last year, and it’s been an exciting and wild ride. With iOS 14, a lot of the rough edges have been smoothed out — is SwiftUI finally ready for production?</p>
<h2 id="fruta-sample-app">Fruta Sample App</h2>
<p>Let’s look at <a href="https://developer.apple.com/documentation/app_clips/fruta_building_a_feature-rich_app_with_swiftui">Apple’s Fruta example</a>, a cross-platform feature-rich app that’s built completely in SwiftUI. It’s great that Apple is finally releasing a more complex application for this year’s cycle.</p>
<p>I took a look when Big Sur beta 1 came out, and it was pretty unpolished:</p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Been clicking around for a minute with Apple's new Fruta SwiftUI sample. <br /><br />Things jump around wildly, fav' doesn't work, and it crashes once you open a second window. I understand it's b1, but looking at how SwiftUI went last year I doubt this will all be fixed. <a href="https://t.co/zGRRYswRde">pic.twitter.com/zGRRYswRde</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1277623561604214784?ref_src=twsrc%5Etfw">June 29, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<p>Since then, there have been many betas, and we’re nearing the end of the cycle, with the GM expected in October. So it’s time to look at Fruta again. And indeed, the SwiftUI team did a great job fixing the various issues: The toolbar is pretty reliable, the sidebar no longer jumps out, multiple windows works… however, <a href="https://twitter.com/steipete/status/1305054121523916806?s=21">views are still sometimes misaligned</a>, and it’s still fairly easy to make it crash on both <a href="https://twitter.com/steipete/status/1305051342596177921?s=21">macOS (FB8682269)</a> and <a href="https://twitter.com/steipete/status/1305052083989684224?s=21">iOS 14b8 (FB8682290)</a>.</p>
<h2 id="swiftui-attributegraph-crashes">SwiftUI AttributeGraph Crashes</h2>
<p>Most SwiftUI crashes are a result of either a diffing issue in AttributeGraph, or a bug with one of the bindings to the platform controls (AppKit or UIKit). Whenever you see <code class="language-plaintext highlighter-rouge">AG::Graph</code> in the stack trace, that’s SwiftUI’s AttributeGraph (written in C++), which takes over representing the view hierarchy and diffing. Crashes there are usually in this form:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre> Fruta[3607:1466511] [error] precondition failure: invalid size for indirect attribute: 25 vs 24
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Googling for this error reveals that there are <a href="https://github.com/fermoya/SwiftUIPager/issues/60">a</a> <a href="https://developer.apple.com/forums/thread/129171">lot</a> <a href="https://stackoverflow.com/questions/58304009/how-to-debug-precondition-failure-in-xcode">of</a> <a href="https://www.reddit.com/r/SwiftUI/comments/fosrbf/precondition_failure_invalid_input_index/">similar</a> <a href="https://twitter.com/steipete/status/1258762457805455361">problems</a>. People sometimes do find workarounds via wrapping views into other views or changing the hierarchy. But mostly, we’re powerless, and this is something Apple needs to fix in its framework. Since SwiftUI ships as part of the OS, end users need to update their devices to get these fixes.</p>
<h2 id="platform-binding-crashes">Platform-Binding Crashes</h2>
<p>SwiftUI uses many components from AppKit and UIKit, which is a much better strategy than reinventing the wheel. These components are stateful and are synced with custom manager classes that perform the state diffing. These wrappers can cause issues, and as they’re written in Swift, there aren’t many possibilities to fix issues from the outside (unlike with swizzling in the earlier days).</p>
<p>Example: Removing a favorited item while it’s selected crashes in the AppKit binding that syncs the SwiftUI state with <code class="language-plaintext highlighter-rouge">NSTableView</code> (FB8684522).</p>
<blockquote class="twitter-tweet" data-conversation="none" data-dnt="true"><p lang="en" dir="ltr">Removing a favorite crashes the app immediately. <a href="https://t.co/i5wiMJr4XX">pic.twitter.com/i5wiMJr4XX</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1305075451711369216?ref_src=twsrc%5Etfw">September 13, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
</pre></td><td class="rouge-code"><pre>2020-09-13 10:31:25.483965+0200 Fruta[79371:2051792] [General] Row 0 out of row range [0--1] for rowViewAtRow:createIfNeeded:
2020-09-13 10:31:25.498144+0200 Fruta[79371:2051792] [General] (
0 CoreFoundation 0x00007fff20cdb0df __exceptionPreprocess + 242
1 libobjc.A.dylib 0x00007fff20b3d469 objc_exception_throw + 48
2 AppKit 0x00007fff237a7905 -[NSTableRowData rowViewAtRow:createIfNeeded:] + 675
3 AppKit 0x00007fff23813008 -[NSTableView viewAtColumn:row:makeIfNecessary:] + 29
4 SwiftUI 0x00007fff49565fc7 $s7SwiftUI19ListCoreCoordinatorC17selectionBehavior5atRow2inAA012PlatformItemC0V0L0V09SelectionG0VSgSi_So11NSTableViewCtF + 39
5 SwiftUI 0x00007fff49566b36 $s7SwiftUI19ListCoreCoordinatorC18selectionDidChange2inySo11NSTableViewC_tF + 2662
6 SwiftUI 0x00007fff49187850 $s7SwiftUI26NSTableViewListCoordinatorC05tableD19SelectionIsChangingyy10Foundation12NotificationVFTm + 112
7 SwiftUI 0x00007fff49187912 $s7SwiftUI26NSTableViewListCoordinatorC05tableD19SelectionIsChangingyy10Foundation12NotificationVFToTm + 114
8 CoreFoundation 0x00007fff20c56a6c __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 12
9 CoreFoundation 0x00007fff20cf23bb ___CFXRegistrationPost_block_invoke + 49
10 CoreFoundation 0x00007fff20cf232f _CFXRegistrationPost + 454
11 CoreFoundation 0x00007fff20c275ce _CFXNotificationPost + 723
12 Foundation 0x00007fff218ba5e2 -[NSNotificationCenter postNotificationName:object:userInfo:] + 59
13 AppKit 0x00007fff237ae588 -[NSTableView _sendSelectionChangedNotificationForRows:columns:] + 219
14 AppKit 0x00007fff2373b739 -[NSTableRowData _updateVisibleViewsBasedOnUpdateItems] + 4503
15 AppKit 0x00007fff2373a453 -[NSTableRowData _updateVisibleViewsBasedOnUpdateItemsAnimated] + 224
16 AppKit 0x00007fff2371a2dc -[NSTableRowData _doWorkAfterEndUpdates] + 95
17 AppKit 0x00007fff2371a19d -[NSTableView _endUpdateWithTile:] + 119
18 SwiftUI 0x00007fff49186759 $s7SwiftUI26NSTableViewListCoordinatorC011updateTableD0_4from2toySo0cD0C_xxtF + 1145
19 SwiftUI 0x00007fff4956c010 $s7SwiftUI19ListCoreCoordinatorC29updateTableViewAndVisibleRows_4from2toySo07NSTableH0C_xxtFyyXEfU_ + 304
20 SwiftUI 0x00007fff4956ccf5 $s7SwiftUI19ListCoreCoordinatorC24withSelectionUpdateGuardyyyyXEF + 53
21 SwiftUI 0x00007fff4956b2ff $s7SwiftUI19ListCoreCoordinatorC29updateTableViewAndVisibleRows_4from2toySo07NSTableH0C_xxtF + 79
</pre></td></tr></tbody></table></code></pre></div></div>
<p>It’s likely there more bugs waiting to be discovered, but I only spent a few hours with Fruta and on writing up this article.</p>
<h2 id="performance">Performance</h2>
<p>On my 2,4 GHz 8-Core Intel Core i9 MacBook Pro, it takes longer than a second to update the main view when changing the selection. This feels sluggish, not to mention it’s significantly longer than even most websites — that load data via the network — need. Fruta has everything local. What’s so slow here? Let’s look at Instruments!</p>
<p><img src="/assets/img/2020/fruta-swiftui/instruments.png" alt="" /></p>
<ul>
<li>Of the 10 seconds captured, 30 percent of them are used for the various retain/release and malloc calls in Swift and Objective-C.</li>
<li><code class="language-plaintext highlighter-rouge">NSAttributedString</code> shows up often in stack traces, which hints that text layout seems especially expensive.</li>
<li>The AttributeGraph SwiftUI layout engine seems to create a lot of throwaway objects. These might mostly be Swift structs, but they’re still expensive.</li>
<li>JPG decoding happens on the main thread, but it’s only responsible for less than 1 percent of the time spent here.</li>
<li>When checking Hide System Libraries, there’s basically no work done in Fruta’s business logic.</li>
<li>Sorting for Top Functions, we see that AppKit’s auto layout logic, combined with SwiftUI’s graph, is taking up a lot of time.</li>
<li>There seems to be a lot of unnecessary invalidation. For example, <code class="language-plaintext highlighter-rouge">AppKitToolbarCoordinator</code> adds a toolbar item, which triggers <code class="language-plaintext highlighter-rouge">NSHostingView.preferencesDidChange()</code>, causing everything to lay itself out once again, even though the toolbar size doesn’t change.</li>
</ul>
<p>The good news is there seem to be a lot of potential future optimizations possible to make this fast. Alternatively, there’s always the possibility of <a href="https://twitter.com/noahsark769/status/1304938866999046144?s=21">dropping out of SwiftUI for performance critical parts</a>.</p>
<p>This isn’t unique to Fruta. I’ve been taking a look at <a href="https://twitter.com/Dimillian">@Dimillian’s</a> RedditOS app, which is built with SwiftUI on macOS. He <a href="https://twitter.com/Dimillian/status/1301802048824979456">stopped development</a> because it’s so slow that it’s not shippable. I did some debugging with an earlier version of Big Sur where the app still somewhat worked:</p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">The AppKit port of SwiftUI is... not very efficient. Been testing <a href="https://t.co/67UCuKmPts">https://t.co/67UCuKmPts</a> for a minute, 10% of the time it freezes on the main thread are just spent to... update toolbar buttons? It's a bit hard to measure because it crashes so quickly. <a href="https://t.co/hW4nNe0ydV">pic.twitter.com/hW4nNe0ydV</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1282655123244752897?ref_src=twsrc%5Etfw">July 13, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<p>The general pattern here points to AppKit: The interaction between SwiftUI views and AppKit views <a href="https://twitter.com/fcbunn/status/1259078251340800000">seems to</a> be <a href="https://twitter.com/stuartcarnie/status/1301895206875181056">poor</a>. It’s important to understand that SwiftUI itself is fast — for many use cases it’s even faster than using <code class="language-plaintext highlighter-rouge">CALayer</code>, <a href="https://twitter.com/cocoawithlove/status/1143859576661393408">as
@cocoawithlove proved</a> — and the UIKit port is by far faster and better than the AppKit port.</p>
<h2 id="update-for-ios-14-gm">Update for iOS 14 GM</h2>
<p>It’s still <a href="https://twitter.com/steipete/status/1306129037719269376?s=21">trivial</a> to crash the SwiftUI C++ AttributeGraph in Apple’s Fruta example on iOS 14 GM.</p>
<h2 id="update-for-big-sur-b9">Update for Big Sur b9</h2>
<p>Apple seems to have fixed an issue and <a href="https://twitter.com/steipete/status/1311028524812308481?s=20">specifically mentioned the Fruta Sample app in the Big Sur Beta Release Notes</a>. However, after testing, it’s still <a href="https://twitter.com/steipete/status/1311244841066561537?s=20">trivially to crash</a>.</p>
<h2 id="update-for-big-sur-b10-and-ios-142b4">Update for Big Sur b10 and iOS 14.2b4</h2>
<p>Apple got back to me, explaining that I should test this again:</p>
<div class="jekyll-twitter-plugin"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">CHALLENGE ACCEPTED <a href="https://t.co/cOgM6fNMXU">pic.twitter.com/cOgM6fNMXU</a></p>— Peter Steinberger (@steipete) <a href="https://twitter.com/steipete/status/1320375101771206662?ref_src=twsrc%5Etfw">October 25, 2020</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
<p>They indeed fixed the easy-to-trigger bug (good job!) and <a href="https://twitter.com/steipete/status/1320375853243617280?s=21">it took me a minute to get it to crash again</a>. To be fair, this is a different issue and seems related to Apple Pay. I also found <a href="https://twitter.com/steipete/status/1320379341507842048?s=21">another Attributed Graph crash</a>.</p>
<p>iOS stabilized as well, however I found <a href="https://twitter.com/steipete/status/1320381666850910209?s=21">a race condition in the List Coordinator</a>. Overall things are really improving fast, the team here is working hard.</p>
<h2 id="conclusion">Conclusion</h2>
<p>If your target platform is iOS 14, you’re now good to go with hobby projects or individual screens in SwiftUI. I’m currently working on making our <a href="http://pspdfkit.com">PDF SDK for iOS</a> easier to use with SwiftUI, and we’ll replace the settings/about screen of <a href="https://pdfviewer.io/">PDF Viewer</a> with a SwiftUI version.</p>
<p>I personally wouldn’t yet go all-in on SwiftUI for production apps, although the crash rate is likely manageable and Apple is actively improving things with every release. Remember that SwiftUI ships with the OS, not with your app, so any bug fixes will only help if your users update the OS.</p>
<p>Other ports are not so great. AppKit seems particularly troublesome, but I’ve also heard of big issues with tvOS. If you need to deploy your app to the Mac, use Catalyst, which is a much more stable binding and feels really good with Big Sur’s native mode, where content is no longer scaled.</p>
<p>If you’re curious about SwiftUI, <strong>please don’t let this dampen your enthusiasm</strong>. It’s extremely fun to write, it’s clearly the future at Apple, and all these issues will surely be resolved within a few years.</p>Peter Steinberger