<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://codakuma.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://codakuma.com/" rel="alternate" type="text/html" /><updated>2026-05-17T12:51:53+00:00</updated><id>https://codakuma.com/feed.xml</id><title type="html">Codakuma</title><subtitle>iOS apps and indie life.</subtitle><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><entry><title type="html">A floating card using safeAreaBar</title><link href="https://codakuma.com/floating-safe-area-bar/" rel="alternate" type="text/html" title="A floating card using safeAreaBar" /><published>2026-05-17T00:00:00+00:00</published><updated>2026-05-17T00:00:00+00:00</updated><id>https://codakuma.com/floating-safe-area-bar</id><content type="html" xml:base="https://codakuma.com/floating-safe-area-bar/"><![CDATA[<p>A pattern I use a lot in <a href="https://apps.apple.com/gb/app/personal-best-workouts/id1510256676">Personal Best</a> is to pin a card to the bottom of the screen containing a summary plus a call to action. Here’s a simple example:</p>

<p><img src="/assets/post-images/2026/05/original.png" alt="Example of a full-width card pinned to the bottom of the screen" class="post-image post-image-small post-image--no-shadow" /></p>

<p>This is easily achieved using SwiftUI’s <a href="https://www.hackingwithswift.com/quick-start/swiftui/how-to-inset-the-safe-area-with-custom-content"><code class="language-plaintext highlighter-rouge">safeAreaInset</code></a>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">List</span><span class="p">(</span><span class="mi">0</span><span class="o">..&lt;</span><span class="mi">100</span><span class="p">)</span> <span class="p">{</span> <span class="n">i</span> <span class="k">in</span>
  <span class="kt">Text</span><span class="p">(</span><span class="s">"Row </span><span class="se">\(</span><span class="n">i</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">safeAreaInset</span><span class="p">(</span><span class="nv">edge</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span><span class="p">)</span> <span class="p">{</span>
  <span class="kt">VStack</span> <span class="p">{</span>
    <span class="kt">Text</span><span class="p">(</span><span class="s">"Some summary content"</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="kt">Spacer</span><span class="p">()</span>
        <span class="kt">Text</span><span class="p">(</span><span class="s">"Save"</span><span class="p">)</span>
        <span class="kt">Spacer</span><span class="p">()</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">buttonStyle</span><span class="p">(</span><span class="o">.</span><span class="n">borderedProminent</span><span class="p">)</span>
  <span class="p">}</span>
  <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">maxWidth</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">)</span>
  <span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
  <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="o">.</span><span class="n">background</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Recently I’ve been evolving PB’s design and one way I’m doing that is by adding a slight inset to these cards, so they float above the content. I feel like it fits in more with iOS 26’s look and feel.</p>

<h2 id="insetting-the-card">Insetting the card</h2>

<p>Insetting the card is easily done by adding some extra styles like padding and corner radius:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// same code as the prior example, plus the below modifiers</span>
<span class="o">.</span><span class="nf">cornerRadius</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
<span class="o">.</span><span class="nf">overlay</span><span class="p">(</span><span class="kt">RoundedRectangle</span><span class="p">(</span><span class="nv">cornerRadius</span><span class="p">:</span> <span class="mi">20</span><span class="p">)</span><span class="o">.</span><span class="nf">stroke</span><span class="p">(</span><span class="o">.</span><span class="n">gray</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.25</span><span class="p">),</span> <span class="nv">lineWidth</span><span class="p">:</span> <span class="mi">1</span><span class="p">))</span>
<span class="o">.</span><span class="nf">shadow</span><span class="p">(</span><span class="nv">color</span><span class="p">:</span> <span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.1</span><span class="p">),</span> <span class="nv">radius</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span>
<span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
</code></pre></div></div>

<p>It doesn’t look quite right though. Even with the border and shadow, it’s hard to tell it apart from the content below.</p>

<p><img src="/assets/post-images/2026/05/initial-inset.png" alt="A card inset into the screen" class="post-image post-image-small post-image--no-shadow" /></p>

<p>Fortunately there’s a simple fix: <a href="https://developer.apple.com/documentation/swiftui/view/safeareabar(edge:alignment:spacing:content:)">safeAreaBar</a>. A safe area bar is functionally the same as a safe area inset except it applies iOS 26’s scroll edge effect where the content blurs, fixing the readability issue.</p>

<p><img src="/assets/post-images/2026/05/comparison.png" alt="Comparison of safeAreaInset and safeAreaBar" class="post-image post-image--no-shadow" /></p>

<h2 id="ios-18-fallback">iOS 18 fallback</h2>

<p>Unfortunately there’s an issue with this: <code class="language-plaintext highlighter-rouge">safeAreaBar</code> requires iOS 26. At the time of writing PB supports iOS 18, so I needed a workaround.</p>

<p>So, I built a fallback that works on iOS 18 combining an <code class="language-plaintext highlighter-rouge">ultraThinMaterial</code> and a gradient to give the content behind the card a soft fade effect.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">safeAreaInset</span><span class="p">(</span><span class="nv">edge</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">myContent</span>
    <span class="o">.</span><span class="n">background</span> <span class="p">{</span>
      <span class="kt">Rectangle</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">fill</span><span class="p">(</span><span class="o">.</span><span class="n">ultraThinMaterial</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">mask</span><span class="p">(</span>
          <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">LinearGradient</span><span class="p">(</span>
              <span class="nv">colors</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">clear</span><span class="p">,</span> <span class="o">.</span><span class="n">black</span><span class="p">],</span>
              <span class="nv">startPoint</span><span class="p">:</span> <span class="o">.</span><span class="n">top</span><span class="p">,</span>
              <span class="nv">endPoint</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span>
            <span class="p">)</span>
            <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">height</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
            <span class="kt">Color</span><span class="o">.</span><span class="n">black</span>
          <span class="p">}</span>
        <span class="p">)</span>
        <span class="o">.</span><span class="nf">ignoresSafeArea</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">VStack</code> inside the mask is the key bit. The top 30 points are a gradient from clear to black, which means the material gradually becomes visible as you scroll down. Below that, the mask is solid black, so the material is fully opaque behind the bar itself.</p>

<p><code class="language-plaintext highlighter-rouge">.ignoresSafeArea()</code> on the background lets the material extend all the way to the bottom edge of the screen, under the home indicator.</p>

<p><img src="/assets/post-images/2026/05/fallback.png" alt="The fallback effect for iOS 18" class="post-image post-image-small post-image--no-shadow" /></p>

<h2 id="making-it-reusable">Making it reusable</h2>

<p>The final step was to abstract this out into a something reusable that would use a <code class="language-plaintext highlighter-rouge">safeAreaBar</code> on iOS 26 and fall back to my custom version on iOS 18. The code for that is below:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">SwiftUI</span>

<span class="kd">extension</span> <span class="kt">View</span> <span class="p">{</span>
  <span class="kd">func</span> <span class="n">floatingSafeAreaBar</span><span class="o">&lt;</span><span class="kt">InsetContent</span><span class="p">:</span> <span class="kt">View</span><span class="o">&gt;</span><span class="p">(</span>
    <span class="kd">@ViewBuilder</span> <span class="nv">insetContent</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">InsetContent</span>
  <span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="nf">modifier</span><span class="p">(</span><span class="kt">FloatingSafeAreaBar</span><span class="p">(</span><span class="nv">insetContent</span><span class="p">:</span> <span class="n">insetContent</span><span class="p">))</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kd">struct</span> <span class="kt">FloatingSafeAreaBar</span><span class="o">&lt;</span><span class="kt">InsetContent</span><span class="p">:</span> <span class="kt">View</span><span class="o">&gt;</span><span class="p">:</span> <span class="kt">ViewModifier</span> <span class="p">{</span>
  <span class="kd">@ViewBuilder</span> <span class="k">let</span> <span class="nv">insetContent</span><span class="p">:</span> <span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">InsetContent</span>

  <span class="kd">@ViewBuilder</span>
  <span class="kd">func</span> <span class="nf">body</span><span class="p">(</span><span class="nv">content</span><span class="p">:</span> <span class="kt">Content</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">if</span> <span class="kd">#available(iOS 26, *)</span> <span class="p">{</span>
      <span class="n">content</span><span class="o">.</span><span class="nf">safeAreaBar</span><span class="p">(</span><span class="nv">edge</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">insetContent</span><span class="p">()</span>
          <span class="o">.</span><span class="nf">modifier</span><span class="p">(</span><span class="kt">CardStyle</span><span class="p">())</span>
      <span class="p">}</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
      <span class="n">content</span><span class="o">.</span><span class="nf">safeAreaInset</span><span class="p">(</span><span class="nv">edge</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">insetContent</span><span class="p">()</span>
          <span class="o">.</span><span class="nf">modifier</span><span class="p">(</span><span class="kt">CardStyle</span><span class="p">())</span>
          <span class="o">.</span><span class="n">background</span> <span class="p">{</span>
            <span class="kt">Rectangle</span><span class="p">()</span>
              <span class="o">.</span><span class="nf">fill</span><span class="p">(</span><span class="o">.</span><span class="n">ultraThinMaterial</span><span class="p">)</span>
              <span class="o">.</span><span class="nf">mask</span><span class="p">(</span>
                <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
                  <span class="kt">LinearGradient</span><span class="p">(</span>
                    <span class="nv">colors</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">clear</span><span class="p">,</span> <span class="o">.</span><span class="n">black</span><span class="p">],</span>
                    <span class="nv">startPoint</span><span class="p">:</span> <span class="o">.</span><span class="n">top</span><span class="p">,</span>
                    <span class="nv">endPoint</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span>
                  <span class="p">)</span>
                  <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">height</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
                  <span class="kt">Color</span><span class="o">.</span><span class="n">black</span>
                <span class="p">}</span>
              <span class="p">)</span>
              <span class="o">.</span><span class="nf">ignoresSafeArea</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>

<span class="kd">private</span> <span class="kd">struct</span> <span class="kt">CardStyle</span><span class="p">:</span> <span class="kt">ViewModifier</span> <span class="p">{</span>
  <span class="kd">func</span> <span class="nf">body</span><span class="p">(</span><span class="nv">content</span><span class="p">:</span> <span class="kt">Content</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="n">content</span>
      <span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
      <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">maxWidth</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="o">.</span><span class="n">background</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">cornerRadius</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">overlay</span><span class="p">(</span><span class="kt">RoundedRectangle</span><span class="p">(</span><span class="nv">cornerRadius</span><span class="p">:</span> <span class="mi">20</span><span class="p">)</span><span class="o">.</span><span class="nf">stroke</span><span class="p">(</span><span class="o">.</span><span class="n">gray</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.25</span><span class="p">),</span> <span class="nv">lineWidth</span><span class="p">:</span> <span class="mi">1</span><span class="p">))</span>
      <span class="o">.</span><span class="nf">shadow</span><span class="p">(</span><span class="nv">color</span><span class="p">:</span> <span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.1</span><span class="p">),</span> <span class="nv">radius</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">CardStyle</code> modifier wraps the inset content in a card with all my styles applied: full width, padding, rounded corners, a background colour, shadow and border.</p>

<h3 id="using-it">Using it</h3>

<p>Now I have a convenient view extension I can use anywhere to get a pre-styled card.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">ScrollView</span> <span class="p">{</span>
  <span class="c1">// ... your scrolling content</span>
<span class="p">}</span>
<span class="o">.</span><span class="n">floatingSafeAreaBar</span> <span class="p">{</span>
  <span class="kt">VStack</span> <span class="p">{</span>
    <span class="kt">Text</span><span class="p">(</span><span class="s">"Some summary content"</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="kt">Spacer</span><span class="p">()</span>
        <span class="kt">Text</span><span class="p">(</span><span class="s">"Save"</span><span class="p">)</span>
        <span class="kt">Spacer</span><span class="p">()</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">buttonStyle</span><span class="p">(</span><span class="o">.</span><span class="n">borderedProminent</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here’s a few screenshots of how it looks in practice in my app:</p>

<p><img src="/assets/post-images/2026/05/in-situ.png" alt="The card in situ" class="post-image post-image--no-shadow" /></p>

<h2 id="the-future">The future</h2>

<p>Even when iOS 26 becomes my app’s minimum supported version this view extension will remain useful for applying consistent styles to these cards throughout the app. I’ll simply remove the fallback code and make it use a <code class="language-plaintext highlighter-rouge">safeAreaBar</code> all the time.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><category term="swiftui" /><summary type="html"><![CDATA[Building a floating card component using iOS 26's safeAreaBar with an iOS 18 fallback]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A ridiculously-lightweight push notification service</title><link href="https://codakuma.com/pushy/" rel="alternate" type="text/html" title="A ridiculously-lightweight push notification service" /><published>2026-04-11T00:00:00+00:00</published><updated>2026-04-11T00:00:00+00:00</updated><id>https://codakuma.com/replacing-onesignal-with-cloudflare-workers</id><content type="html" xml:base="https://codakuma.com/pushy/"><![CDATA[<p>My app <a href="https://apps.apple.com/gb/app/personal-best-workouts/id1510256676">Personal Best</a> uses local (on device) notifications for most things. In addition to that it supports push notifications, like for sales or when new features become available. I typically only use this once or twice per year but it’s nice to have in my back pocket for when I need it.</p>

<p>For this I was using <a href="https://onesignal.com">OneSignal</a> which is <em>fine</em> but felt like overkill for my use case. Also I really didn’t like having to add their SDK to my project. It’s over 23,000 lines of code, requires its own notification service extension target, and phones home on every app launch. I try to keep my dependencies very minimal, so I was never happy about having to include this.</p>

<p>So, I replaced it with a <a href="https://workers.cloudflare.com">Cloudflare Worker</a> that’s about 200 lines of TypeScript plus a few of lines of Swift in the app. The whole thing costs nothing on the free tier.</p>

<h2 id="what-i-actually-need">What I actually need</h2>

<p>My requirements are simple:</p>

<ul>
  <li>Store device tokens so I can send notifications later</li>
  <li>Know whether each user has Personal Best Pro or not, so I can target messages</li>
  <li>A basic admin UI to send messages</li>
</ul>

<p>I’ve deliberately kept it very lightweight for now. I might want to add things like media attachments later, but I’m not worrying about that right now.</p>

<h2 id="the-service-pushy">The service: Pushy</h2>

<p>I built a Cloudflare Worker called Pushy. It has three endpoints:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">POST /tokens</code></strong> – register a device token. Accepts a JSON body with the token and whether the user has Pro.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">DELETE /tokens</code></strong> – deregister a device token. Used when someone opts out of notifications.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">GET /admin</code></strong> – a simple HTML page where I can compose and send a notification.</li>
</ul>

<p>Tokens are stored in <a href="https://developers.cloudflare.com/kv/">Cloudflare KV</a>, which is a key-value store. Each token is a key, and the value is some metadata (pro status, registration date). When I send a notification, the worker reads all tokens from KV and sends each one to APNs directly.</p>

<h3 id="sending-to-apns">Sending to APNs</h3>

<p>This was the part I expected to be hard, but <a href="https://ryan-george-verse.fandom.com/wiki/Super_easy,_barely_an_inconvenience">actually it was super easy, barely an inconvenience</a>. Apple’s Push Notification service has an <a href="https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns">HTTP/2 API</a>. You POST a JSON payload to <code class="language-plaintext highlighter-rouge">https://api.push.apple.com/3/device/{token}</code> with a signed JWT in the header. Cloudflare Workers support the crypto APIs needed to sign the JWT, so no external dependencies are required.</p>

<p>If APNs returns a 410, the token is stale (the user uninstalled, or their token rotated). Pushy deletes these automatically, so the token list stays clean over time.</p>

<h2 id="the-app-side">The app side</h2>

<p>On the iOS side, all I needed was to:</p>

<ol>
  <li>Get the APNs device token when the system provides it</li>
  <li><code class="language-plaintext highlighter-rouge">POST</code> it to Pushy on registration</li>
  <li><code class="language-plaintext highlighter-rouge">DELETE</code> it from Pushy on opt-out</li>
  <li>Skip redundant calls if nothing has changed</li>
</ol>

<p>The networking code is minimal:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">sendPushyRequest</span><span class="p">(</span><span class="nv">method</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">body</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])</span> <span class="k">async</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
  <span class="k">guard</span> <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"</span><span class="se">\(</span><span class="n">baseURL</span><span class="se">)</span><span class="s">/tokens"</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="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">URLRequest</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
  <span class="n">request</span><span class="o">.</span><span class="n">httpMethod</span> <span class="o">=</span> <span class="n">method</span>
  <span class="n">request</span><span class="o">.</span><span class="nf">setValue</span><span class="p">(</span><span class="n">appKey</span><span class="p">,</span> <span class="nv">forHTTPHeaderField</span><span class="p">:</span> <span class="s">"X-App-Key"</span><span class="p">)</span>
  <span class="n">request</span><span class="o">.</span><span class="nf">setValue</span><span class="p">(</span><span class="s">"application/json"</span><span class="p">,</span> <span class="nv">forHTTPHeaderField</span><span class="p">:</span> <span class="s">"Content-Type"</span><span class="p">)</span>
  <span class="n">request</span><span class="o">.</span><span class="n">httpBody</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">JSONSerialization</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">withJSONObject</span><span class="p">:</span> <span class="n">body</span><span class="p">)</span>
  <span class="k">guard</span> <span class="k">let</span> <span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="nv">response</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</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="nf">return</span> <span class="p">(</span><span class="n">response</span> <span class="k">as?</span> <span class="kt">HTTPURLResponse</span><span class="p">)?</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">==</span> <span class="mi">200</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both register and deregister use this same method, just with different HTTP methods and bodies. On success, a local flag in <code class="language-plaintext highlighter-rouge">UserDefaults</code> is updated so we don’t make the same call again next launch. This matters because Cloudflare KV has a write limit on the free tier, and there’s no point burning a write when nothing has changed.</p>

<h3 id="capturing-the-device-token">Capturing the device token</h3>

<p>To get the APNs token, you implement <code class="language-plaintext highlighter-rouge">didRegisterForRemoteNotificationsWithDeviceToken</code> in your app delegate:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">application</span><span class="p">(</span>
  <span class="n">_</span> <span class="nv">application</span><span class="p">:</span> <span class="kt">UIApplication</span><span class="p">,</span>
  <span class="n">didRegisterForRemoteNotificationsWithDeviceToken</span> <span class="nv">deviceToken</span><span class="p">:</span> <span class="kt">Data</span>
<span class="p">)</span> <span class="p">{</span>
  <span class="k">let</span> <span class="nv">token</span> <span class="o">=</span> <span class="n">deviceToken</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="kt">String</span><span class="p">(</span><span class="nv">format</span><span class="p">:</span> <span class="s">"%02x"</span><span class="p">,</span> <span class="nv">$0</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="nf">joined</span><span class="p">()</span>
  <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="s">"deviceToken"</span><span class="p">)</span>
  <span class="kt">Task</span> <span class="p">{</span> <span class="k">await</span> <span class="kt">PushNotificationsManager</span><span class="o">.</span><span class="nf">register</span><span class="p">(</span><span class="nv">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span> <span class="nv">pro</span><span class="p">:</span> <span class="n">hasProAccess</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The token is stored locally so it’s available for deregistration later, without needing another system call.</p>

<h2 id="gotchas">Gotchas</h2>

<h3 id="sandbox-vs-production-apns">Sandbox vs production APNs</h3>

<p>APNs has two environments: sandbox (for debug builds from Xcode) and production (for TestFlight and App Store builds). They use different endpoints and a token from one won’t work on the other. If you’re testing with a debug build and sending to <code class="language-plaintext highlighter-rouge">api.push.apple.com</code>, you’ll get a 410 and your token will be deleted.</p>

<p>I added an environment picker to the admin UI so I can switch between sandbox and production without redeploying.</p>

<h3 id="kv-write-limits">KV write limits</h3>

<p>Cloudflare KV’s free tier allows 1,000 writes per day. With the deduplication logic in the app (only register if the token or pro status actually changed), steady-state writes are minimal. The spike to watch for is the initial rollout when every user registers for the first time. The <a href="https://www.cloudflare.com/plans/developer-platform/">$5/month paid plan</a> gives you 1 million writes per month, which is more than enough headroom.</p>

<h2 id="what-i-removed">What I removed</h2>

<p>Thanks to this I was able to remove:</p>

<ul>
  <li>The OneSignal SPM package (154 files, 23K+ lines)</li>
  <li>The <code class="language-plaintext highlighter-rouge">OneSignalNotificationServiceExtension</code> target and its files</li>
</ul>

<h2 id="was-it-worth-it">Was it worth it?</h2>

<p>For my use case, absolutely. The Cloudflare Worker is simple enough that I can understand every line of it, it costs nothing, and I have complete control over the data.</p>

<p>If you’re sending millions of notifications, doing rich media, running notification A/B tests, or need delivery analytics, you probably want something more full featured like OneSignal or Firebase. But if your needs are minimal (like me), something like this is very doable.</p>

<p>Every decision is a trade off. TWhen I used OneSignal they were responsible for ensuring the service remained online, and now I’m in a position where I’m responsible if Cloudflare goes down or there’s a bug in my code, For me the trade off of getting rid of a massive dependency and having more control over things is worth the downsides.</p>

<h2 id="apple-should-offer-this-natively-pls-apple-">Apple should offer this natively (pls Apple 🫶)</h2>

<p>Apple already has a <a href="https://developer.apple.com/notifications/push-notifications-console/">push notifications console</a>, but it only allows sending either <a href="https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns">broadcast notifications</a> for updating Live Activities, or sending to a single individual user.</p>

<p>It’d be amazing if there was a nice first-party SDK that could take care of storing device tokens for users, and then if the push notifications console allowed us to target those stored users. I’ll keep my fingers crossed for next WWDC.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><category term="swift" /><summary type="html"><![CDATA[How I made my own tiny push notification service]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A simple logging framework in Swift</title><link href="https://codakuma.com/swift-logging/" rel="alternate" type="text/html" title="A simple logging framework in Swift" /><published>2026-02-22T00:00:00+00:00</published><updated>2026-02-22T00:00:00+00:00</updated><id>https://codakuma.com/simple-logging-framework-swift</id><content type="html" xml:base="https://codakuma.com/swift-logging/"><![CDATA[<p>Up to now I’ve done debugging by connecting my phone to Xcode and scrolling through the console. This works fine at your desk, but not so much when a user reports a bug with “it crashed when I opened the app” and nothing else to go on. Or when you hit a bug while you’re out, make a mental note, and forget the details by the time you’re home.</p>

<p>I wanted structured logs I could retrieve later, without adding a third-party dependency or paying for a service. Here’s what I did.</p>

<h2 id="choosing-a-solution">Choosing a solution</h2>

<p>Apple’s <a href="https://developer.apple.com/documentation/os/logging">OSLog system</a> is built into their platforms but I didn’t even know it existed until now.</p>

<p>It has lots of benefits like automatic log rotation so logs never grow too large, and it doesn’t persist debug and info logs by default, so you can log liberally during development without worrying about bloat in production. It also supports categorising your logs, which makes filtering through hundreds of entries much easier.</p>

<p>I built a lightweight wrapper over it called <code class="language-plaintext highlighter-rouge">LogManager</code>.</p>

<h2 id="logmanager">LogManager</h2>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">import</span> <span class="kt">OSLog</span>

<span class="kd">struct</span> <span class="kt">LogManager</span> <span class="p">{</span>

  <span class="kd">enum</span> <span class="kt">LogCategory</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">networking</span>
    <span class="k">case</span> <span class="n">sync</span>
    <span class="c1">// Add more categories here as needed</span>
    <span class="k">case</span> <span class="nf">custom</span><span class="p">(</span><span class="kt">String</span><span class="p">)</span>

    <span class="k">var</span> <span class="nv">stringValue</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
      <span class="k">switch</span> <span class="k">self</span> <span class="p">{</span>
      <span class="k">case</span> <span class="o">.</span><span class="nv">networking</span><span class="p">:</span> <span class="k">return</span> <span class="s">"networking"</span>
      <span class="k">case</span> <span class="o">.</span><span class="nv">sync</span><span class="p">:</span> <span class="k">return</span> <span class="s">"sync"</span>
      <span class="k">case</span> <span class="o">.</span><span class="nf">custom</span><span class="p">(</span><span class="k">let</span> <span class="nv">value</span><span class="p">):</span> <span class="k">return</span> <span class="n">value</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">subsystem</span> <span class="o">=</span> <span class="kt">Bundle</span><span class="o">.</span><span class="n">main</span><span class="o">.</span><span class="n">bundleIdentifier</span> <span class="p">??</span> <span class="s">"com.example.myapp"</span>

  <span class="kd">private</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">logger</span><span class="p">(</span><span class="k">for</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Logger</span> <span class="p">{</span>
    <span class="kt">Logger</span><span class="p">(</span><span class="nv">subsystem</span><span class="p">:</span> <span class="n">subsystem</span><span class="p">,</span> <span class="nv">category</span><span class="p">:</span> <span class="n">category</span><span class="o">.</span><span class="n">stringValue</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">debug</span><span class="p">(</span><span class="n">_</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">debug</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">info</span><span class="p">(</span><span class="n">_</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">info</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">notice</span><span class="p">(</span><span class="n">_</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">notice</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">error</span><span class="p">(</span><span class="n">_</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">error</span><span class="p">(</span><span class="n">_</span> <span class="nv">error</span><span class="p">:</span> <span class="kt">Error</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">,</span> <span class="nv">context</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span> <span class="o">=</span> <span class="kc">nil</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">message</span> <span class="o">=</span> <span class="k">if</span> <span class="k">let</span> <span class="nv">context</span> <span class="o">=</span> <span class="n">context</span> <span class="p">{</span>
      <span class="s">"</span><span class="se">\(</span><span class="n">context</span><span class="se">)</span><span class="s">: </span><span class="se">\(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="se">)</span><span class="s">"</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
      <span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span>
    <span class="p">}</span>
    <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">fault</span><span class="p">(</span><span class="n">_</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">fault</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Usage is straightforward:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">LogManager</span><span class="o">.</span><span class="nf">notice</span><span class="p">(</span><span class="s">"Starting sync"</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">sync</span><span class="p">)</span>

<span class="k">do</span> <span class="p">{</span>
  <span class="k">try</span> <span class="k">await</span> <span class="nf">performSync</span><span class="p">()</span>
  <span class="kt">LogManager</span><span class="o">.</span><span class="nf">notice</span><span class="p">(</span><span class="s">"Sync completed successfully"</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">sync</span><span class="p">)</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
  <span class="kt">LogManager</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="n">error</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">sync</span><span class="p">,</span> <span class="nv">context</span><span class="p">:</span> <span class="s">"Sync failed"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And because we’re using a thin wrapper around <code class="language-plaintext highlighter-rouge">OSLog</code>, if I ever want to integrate with a third-party service in future, it’ll require very minimal code changes:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">error</span><span class="p">(</span><span class="n">_</span> <span class="nv">message</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">category</span><span class="p">:</span> <span class="kt">LogCategory</span><span class="p">)</span> <span class="p">{</span>
  <span class="nf">logger</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">category</span><span class="p">)</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">message</span><span class="p">,</span> <span class="nv">privacy</span><span class="p">:</span> <span class="o">.</span><span class="kd">public</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="kt">SomeRemoteLoggingService</span><span class="o">.</span><span class="nf">send</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="nv">level</span><span class="p">:</span> <span class="o">.</span><span class="n">error</span><span class="p">,</span> <span class="nv">category</span><span class="p">:</span> <span class="n">category</span><span class="o">.</span><span class="n">stringValue</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Be aware that by default <code class="language-plaintext highlighter-rouge">OSLog</code> redacts dynamic strings in production, so you’d just see <code class="language-plaintext highlighter-rouge">&lt;private&gt;</code>. I’m only logging operational stuff (no user data), so <code class="language-plaintext highlighter-rouge">.public</code> is fine for me, but it might not be for you.</p>

<h2 id="exporting-logs">Exporting logs</h2>

<p>I also needed a way to get logs <strong>off</strong> the device — both for me when I’m not connected to the debugger, and for users reporting issues.</p>

<p><code class="language-plaintext highlighter-rouge">OSLog</code> entries live in an <code class="language-plaintext highlighter-rouge">OSLogStore</code> that you can query directly:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">import</span> <span class="kt">OSLog</span>

<span class="kd">func</span> <span class="nf">exportLogs</span><span class="p">(</span><span class="nv">lastHours</span><span class="p">:</span> <span class="kt">Int</span> <span class="o">=</span> <span class="mi">24</span><span class="p">)</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">URL</span> <span class="p">{</span>
  <span class="k">let</span> <span class="nv">store</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">OSLogStore</span><span class="p">(</span><span class="nv">scope</span><span class="p">:</span> <span class="o">.</span><span class="n">currentProcessIdentifier</span><span class="p">)</span>
  <span class="k">let</span> <span class="nv">date</span> <span class="o">=</span> <span class="kt">Date</span><span class="o">.</span><span class="n">now</span><span class="o">.</span><span class="nf">addingTimeInterval</span><span class="p">(</span><span class="o">-</span><span class="kt">Double</span><span class="p">(</span><span class="n">lastHours</span><span class="p">)</span> <span class="o">*</span> <span class="mi">3600</span><span class="p">)</span>
  <span class="k">let</span> <span class="nv">position</span> <span class="o">=</span> <span class="n">store</span><span class="o">.</span><span class="nf">position</span><span class="p">(</span><span class="nv">date</span><span class="p">:</span> <span class="n">date</span><span class="p">)</span>
  <span class="k">let</span> <span class="nv">bundleIdentifier</span> <span class="o">=</span> <span class="kt">Bundle</span><span class="o">.</span><span class="n">main</span><span class="o">.</span><span class="n">bundleIdentifier</span> <span class="p">??</span> <span class="s">"com.example.myapp"</span>

  <span class="k">let</span> <span class="nv">entries</span> <span class="o">=</span> <span class="k">try</span> <span class="n">store</span>
    <span class="o">.</span><span class="nf">getEntries</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">position</span><span class="p">)</span>
    <span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nv">$0</span> <span class="k">as?</span> <span class="kt">OSLogEntryLog</span> <span class="p">}</span>
    <span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">subsystem</span> <span class="o">==</span> <span class="n">bundleIdentifier</span> <span class="p">}</span>

  <span class="k">var</span> <span class="nv">logLines</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
  <span class="n">logLines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"App Logs"</span><span class="p">)</span>
  <span class="n">logLines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"Generated: </span><span class="se">\(</span><span class="kt">Date</span><span class="o">.</span><span class="n">now</span><span class="o">.</span><span class="nf">formatted</span><span class="p">(</span><span class="nv">date</span><span class="p">:</span> <span class="o">.</span><span class="n">complete</span><span class="p">,</span> <span class="nv">time</span><span class="p">:</span> <span class="o">.</span><span class="n">complete</span><span class="p">)</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="n">logLines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"Period: Last </span><span class="se">\(</span><span class="n">lastHours</span><span class="se">)</span><span class="s"> hours"</span><span class="p">)</span>
  <span class="n">logLines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="nv">repeating</span><span class="p">:</span> <span class="s">"="</span><span class="p">,</span> <span class="nv">count</span><span class="p">:</span> <span class="mi">80</span><span class="p">))</span>
  <span class="n">logLines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">""</span><span class="p">)</span>

  <span class="k">for</span> <span class="n">entry</span> <span class="k">in</span> <span class="n">entries</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">timestamp</span> <span class="o">=</span> <span class="n">entry</span><span class="o">.</span><span class="n">date</span><span class="o">.</span><span class="nf">formatted</span><span class="p">(</span><span class="nv">date</span><span class="p">:</span> <span class="o">.</span><span class="n">omitted</span><span class="p">,</span> <span class="nv">time</span><span class="p">:</span> <span class="o">.</span><span class="n">standard</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">level</span> <span class="o">=</span> <span class="nf">levelString</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">entry</span><span class="o">.</span><span class="n">level</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">category</span> <span class="o">=</span> <span class="n">entry</span><span class="o">.</span><span class="n">category</span><span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="nv">toLength</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span> <span class="nv">withPad</span><span class="p">:</span> <span class="s">" "</span><span class="p">,</span> <span class="nv">startingAt</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span>
    <span class="n">logLines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"[</span><span class="se">\(</span><span class="n">timestamp</span><span class="se">)</span><span class="s">] [</span><span class="se">\(</span><span class="n">level</span><span class="se">)</span><span class="s">] [</span><span class="se">\(</span><span class="n">category</span><span class="se">)</span><span class="s">] </span><span class="se">\(</span><span class="n">entry</span><span class="o">.</span><span class="n">composedMessage</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="k">let</span> <span class="nv">logsString</span> <span class="o">=</span> <span class="n">logLines</span><span class="o">.</span><span class="nf">joined</span><span class="p">(</span><span class="nv">separator</span><span class="p">:</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>

  <span class="k">let</span> <span class="nv">tempDirectory</span> <span class="o">=</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="n">temporaryDirectory</span>
  <span class="k">let</span> <span class="nv">fileURL</span> <span class="o">=</span> <span class="n">tempDirectory</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"app-logs-</span><span class="se">\(</span><span class="kt">Int</span><span class="p">(</span><span class="kt">Date</span><span class="p">()</span><span class="o">.</span><span class="n">timeIntervalSince1970</span><span class="p">)</span><span class="se">)</span><span class="s">.txt"</span><span class="p">)</span>
  <span class="k">try</span> <span class="n">logsString</span><span class="o">.</span><span class="nf">write</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">fileURL</span><span class="p">,</span> <span class="nv">atomically</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span>

  <span class="k">return</span> <span class="n">fileURL</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This grabs the last 24 hours of your app’s logs, formats them into a text file, and gives you a URL you can hand to a share sheet. The output looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>App Logs
Generated: Saturday, 22 February 2026 at 14:30:00 GMT
Period: Last 24 hours
================================================================================

[09:15:23] [NOTIC] [sync               ] Starting sync
[09:15:24] [NOTIC] [sync               ] Sync completed successfully
[09:22:01] [ERROR] [networking          ] Request failed: The operation timed out
[10:45:12] [NOTIC] [notifications       ] Scheduled notification: Reminder
</code></pre></div></div>

<p>The categories from <code class="language-plaintext highlighter-rouge">LogManager</code> carry through here, so you can quickly scan for the area you care about. I have an ‘Export logs’ button in my app which brings up the share sheet with the file, which I can either send to myself or have a user send to me.</p>

<h2 id="thats-it">That’s it</h2>

<p>I can now debug issues that happen away from Xcode, and get better user reports.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><category term="swift" /><summary type="html"><![CDATA[How I built a lightweight logging system using OSLog]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing Tally</title><link href="https://codakuma.com/meet-tally/" rel="alternate" type="text/html" title="Introducing Tally" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T00:00:00+00:00</updated><id>https://codakuma.com/2025-meet-tally</id><content type="html" xml:base="https://codakuma.com/meet-tally/"><![CDATA[<p>Now that I’m doing contract and freelance work I have to make invoices a lot. This takes me a couple of minutes using a Pages template, but the invoices it makes aren’t very nice looking (to my eyes, at least).</p>

<p><img src="/assets/post-images/2026/02/pages-invoice.png" alt="Sample invoice made using Pages" class="post-image post-image-small" />
<em>An example invoice made with Pages</em></p>

<p>I could have just spent ten minutes making a nicer Pages template, but like all good programmers I decided to instead invest a significant amount of time into building a bespoke solution.</p>

<p>So – meet <a href="https://apps.apple.com/app/tally-pdf-invoice-creator/id6758414743">Tally</a> – a simple invoicing app.</p>

<p><img src="/assets/post-images/2026/02/meet-tally.png" alt="Screenshots of Tally" class="post-image post-image" /></p>

<p>I didn’t want to distract myself from focusing on <a href="https://getpersonalbest.com">Personal Best</a> too much, so v1 of Tally has a modest feature set:</p>

<ul>
  <li>Save your business and payment details</li>
  <li>Add your customers</li>
  <li>Make ‘one off’ and ‘day rate’ invoices</li>
  <li>Export invoices as PDFs</li>
</ul>

<p>Everything automatically syncs over iCloud, and it supports iOS, iPadOS, macOS, and visionOS.</p>

<h2 id="whats-next">What’s next</h2>

<p>I’m going to leave Tally alone now for a few months while I focus on Personal Best, but when I come back to it I’ll start evolving it into a fully-fledged billing platform for freelancers. It’s easy to imagine places this could go, like:</p>

<ul>
  <li>Other types of invoice, like hourly rates</li>
  <li>Purchases orders, quote, and credit notes</li>
  <li>Marking invoices as paid, late, etc</li>
  <li>Emailing out invoices automatically, including reminders and late notifications based on your payment terms</li>
  <li>Static links for invoices, so you can send a link to your client for them to pay online</li>
  <li>Web and Android support</li>
  <li>Monetisation – v1 of Tally is free, but if I pursue this properly I’ll gate some features behind a paywall</li>
</ul>

<h2 id="download-tally">Download Tally</h2>

<p><a href="https://apps.apple.com/app/tally-pdf-invoice-creator/id6758414743">Get Tally on the App Store</a></p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><summary type="html"><![CDATA[My new invoicing app]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">2025 in review</title><link href="https://codakuma.com/2025-in-review/" rel="alternate" type="text/html" title="2025 in review" /><published>2026-01-01T00:00:00+00:00</published><updated>2026-01-01T00:00:00+00:00</updated><id>https://codakuma.com/2025-in-review</id><content type="html" xml:base="https://codakuma.com/2025-in-review/"><![CDATA[<p>Here’s my annual write up of how indie developer life went in the past year.</p>

<p><em>If you’d like to catch up on previous years first, check out <a href="/2024-in-review/">2024</a>, <a href="/2023-in-review/">2023</a>, <a href="/im-feeling-22/">2022</a>, <a href="/2021-in-review/">2021</a>, and <a href="/2020-in-review/">2020</a>.</em></p>

<h2 id="overall">Overall</h2>

<p>It’s been a good, albeit unexpected year. When the year started I was working full time and spending evenings and weekends on <a href="https://apps.apple.com/gb/app/personal-best-workouts/id1510256676">Personal Best</a> (PB). Unfortunately in March the startup I was working at closed down unexpectedly. So, I decided to take some time to focus on PB exclusively, working on it full time for around six months.</p>

<p>In September I started contracting part time, so I now contract three days a week and spend the rest of my time focusing on PB. I’ve been wanting to get into contracting for some time and I’m really happy that I now have the opportunity to do so.</p>

<h2 id="my-goals">My goals</h2>

<p>In <a href="/2024-in-review/">2024</a> I set myself three loose goals for 2025:</p>

<h3 id="focus-on-personal-best">Focus on Personal Best</h3>

<p><strong>Status:</strong> Achieved ✅</p>

<p>I wanted to make PB my main focus this year and I pretty much succeeded. I was tempted to build some other apps – I have a few ideas knocking about in my head – but I managed to resist the urge to build them.</p>

<h3 id="continue-refactoring">Continue refactoring</h3>

<p><strong>Status:</strong> Achieved ✅</p>

<p>I addressed a bunch of tech debt in PB (more on that later). I was really glad to have finally spent some time improving PB’s foundations.</p>

<h3 id="build-goal-tracking">Build goal tracking</h3>

<p><strong>Status:</strong> In progress ⏳</p>

<p>I’m about 75% of the way through building this, but it isn’t quite ready yet. I plan to release it in early 2026.</p>

<h2 id="what-i-shipped">What I shipped</h2>

<p>I did a <strong>lot</strong> of work on PB this year and shipped a bunch of stuff, some user facing, others behind the scenes.</p>

<h3 id="pricing-and-paywall-tweaks">Pricing and paywall tweaks</h3>

<p>I shipped a new paywall and ran several experiments to tweak it further, testing things like layout, trial length, pricing, and button text. I used <a href="https://www.revenuecat.com/">RevenueCat</a> and <a href="https://telemetrydeck.com/">TelemetryDeck</a> to run my experiments and I’m a very happy customer of both. I’m pretty much now always running an experiment of some kind on the paywall.</p>

<p>I also switched to <a href="https://www.solvimon.com/glossary/region-based-pricing">region-specific pricing</a> for Personal Best Pro. I used the excellent <a href="https://macpricetag.com/">Pricetag</a> app to do this with the <a href="https://subscriptionland.com/services/apple_music/individual/USD">Apple Music Index</a> as the basis for my pricing. In practice this means that PB’s subscription price now reflects relative purchasing power in different regions – for example in India the price dropped by 90%. As a result my revenue in India in 2025 was <strong>128% higher</strong> than in 2024. This means that the price cut is easily offset by how many more people purchased the app with a more accessible price.</p>

<p>Another pricing tweak I made was adding discounts. If new users don’t choose to start a trial during onboarding, they’ll be offered a year at 50% off at a later date. Similarly, users who <strong>had</strong> a subscription but then churned will be offered 50% off another year.</p>

<h3 id="revamped-notifications">Revamped notifications</h3>

<p>PB has had post-workout notifications for a while, but they were behind the paywall. I decided to make them available to all users – not just subscribers – so that they could act as a growth driver. I also integrated them into onboarding with an <a href="https://www.appcues.com/blog/aha-moment-guide">‘a-ha moment’</a> to demonstrate their value to users as early as possible.</p>

<video class="post-image post-image-small" autoplay="" loop="">
  <source src="/assets/post-images/2025/12/onboarding.mp4" />
</video>
<p class="caption">Users see an example notification during onboarding to give them an idea of what they'll get</p>

<p>Post-workout notifications got a lot richer in 2025 too. I learned about a little-used API on iOS – <a href="https://developer.apple.com/documentation/UserNotificationsUI/customizing-the-appearance-of-notifications">Notification Content App Extensions</a>, which allows apps to display any UI when a notification is expanded. You’ve probably seen this before in the Messages app which opens a small version of the conversation when expanded. Thanks to this, post-workout notifications now display a rich view of your workout when expanded.</p>

<video class="post-image post-image-small" autoplay="" loop="">
  <source src="/assets/post-images/2025/12/rich-notifications.mp4" />
</video>
<p class="caption">An expanded notification displaying rich content</p>

<p>I also added server-driven notifications powered by <a href="https://onesignal.com">OneSignal</a>, so I can notify users about things like when their <em>Year in Review</em> is available. I plan to use this sparingly – nobody likes an app that spams you with annoying notifications.</p>

<h3 id="workout-planning">Workout planning</h3>

<p>My vision for PB is for it to eventually do everything you need to work out at your best. To take another step towards that I added workout planning.</p>

<p>Users can plan a workout and PB will make suggestions based on their recent history. Once they’ve saved the plan it’ll be pinned to their app (and the new <em>Today</em> widget) for the rest of the day, and PB will motivate them to complete it, including sending notifications when it’s close to the end of the day (think <em>Duolingo</em>).</p>

<p>Once they’ve completed their workout, PB’s post-workout notifications and the app shows them how they did compared to what they planned. If they don’t complete their workout they can roll it over to the next day and try again.</p>

<video class="post-image post-image-small" autoplay="" loop="">
  <source src="/assets/post-images/2025/12/plan.mp4" />
</video>
<p class="caption">The workout planning flow</p>

<h3 id="new-design-and-ios-26-support">New design and iOS 26 support</h3>

<p>I’ve started to slowly evolve PB to be more ‘branded’ and look less like the Settings app. I’m still a long way from where I want it to be (it’s a slow evolution), but I took the first steps this year with some new gradients I’m using in parts of the app.</p>

<p><img src="/assets/post-images/2025/12/gradient-example.png" alt="Example of the new gradients" class="post-image post-image-small post-image--no-shadow" />
<em>Example of the new gradients. It uses a mesh gradient combined with a noise effect.</em></p>

<p>I also made sure that PB was ready for iOS 26 on day one. I was very fortunate to get to work with Apple over the summer on adapting PB to the new design and to use the <a href="https://developer.apple.com/documentation/FoundationModels">Foundation Models</a> framework, which was a great experience.</p>

<p>Thanks to this I was lucky to be featured by the App Store editors for the iOS 26 launch, and PB was also included in some of Apple’s internal slides shown to developer workshops as an example of best practice.</p>

<p><img src="/assets/post-images/2025/12/it-me.png" alt="Being featured by Apple" class="post-image post-image-small post-image--no-shadow" />
<em>Being shown by Apple as an example to other developers 🤯</em></p>

<h3 id="other-things-i-shipped">Other things I shipped</h3>

<p><em>Things that don’t merit their own section</em></p>

<ul>
  <li>Users can now find their past workouts with powerful filters on the <em>Workouts</em> tab.</li>
  <li>The <em>Dashboard</em> tab became the <em>Today</em> tab, and users can now customise it by choosing which sections appear.</li>
  <li>PB now works great at all dynamic type sizes. See <a href="/dynamic-type">my previous post</a> for more on this.</li>
  <li>I updated PB’s <em>Year In Review</em> feature (think <em>Spotify Wrapped</em> but for workouts) for 2025.</li>
  <li>The <em>Dashboard</em> tab now displays a rotating list of facts about your latest workouts.</li>
  <li>The leaderboards widget gained support for custom leaderboards.</li>
  <li>I spent some time improving PB’s <a href="https://appradar.com/academy/what-is-app-store-optimization-aso">ASO</a> by experimenting with different keywords and screenshots on the App Store.</li>
  <li>Users can now manually add (and delete) workouts.</li>
  <li>I completed the database migration started in 2024. PB now uses Swift Data for everything; Core Data is no more.</li>
  <li>PB now identifies and ignores duplicate workouts. Previously PB would show all workouts, so when a misbehaving app added multiple entries (e.g. Strava) to HealthKit users would see multiple of the same workout. This no longer happens.</li>
  <li>I made a bunch of new app icons featuring photos from around the world that I took during workouts.</li>
  <li>I replaced the old contact form which opened the user’s mail app with a slicker one which sends me feedback directly. I also added a <a href="https://developer.apple.com/design/human-interface-guidelines/home-screen-quick-actions">Home Screen Quick Action</a> to open the feedback form when users are thinking of deleting the app.</li>
  <li>I posted a lot more on PB’s <a href="https://www.instagram.com/getpersonalbest">Instagram</a> and <a href="https://www.tiktok.com/@personalbestapp">TikTok</a> accounts.</li>
  <li>The iPad version of PB gained support for keyboard shortcuts.</li>
</ul>

<h2 id="metrics">Metrics</h2>

<p><em>TL;DR: Downloads have decreased, revenue is up, users are stable.</em></p>

<h3 id="downloads">Downloads</h3>

<p>PB’s downloads in 2025 were down compared to 2024. It went from <strong>180,000</strong> downloads in 2024 to <strong>137,000</strong> in 2025.</p>

<p>While this is slightly disappointing, I’m not overly concerned as revenue has increased year on year (see next section).</p>

<p>My other four apps are pretty much a rounding error compared to PB at this point. I’m fine with this, as I’ve put zero work into them this year and they’re quite niche.</p>

<p><img src="/assets/post-images/2025/12/2025-downloads.png" alt="Chart of downloads from 2020 to 2025" class="post-image post-image--no-shadow" />
<em>Downloads from 2020-2025</em></p>

<h3 id="revenue">Revenue</h3>

<p>Sales are up in 2025, from <strong>$35,000</strong> in 2024 to <strong>$45,000</strong> in 2025, an increase of 29%. I’m particularly happy about this, particularly when factoring in the lower downloads in 2025, as it indicates that my efforts to drive revenue are working.</p>

<p><img src="/assets/post-images/2025/12/2025-sales.png" alt="Chart of sales from 2020 to 2025" class="post-image post-image--no-shadow" />
<em>Sales from 2020-2025</em></p>

<p>RevenueCat breaks my income into monthly recurring revenue (MRR). On December 31st 2025 my MRR was <strong>$3,630</strong>, a 31% increase over the <strong>$2,769</strong> I had on the same date in 2024.</p>

<p><img src="/assets/post-images/2025/12/2025-mrr.png" alt="Chart of MRR from 2022 to 2025" class="post-image post-image--no-shadow" />
<em>MRR from 2022-2025. It starts in 2022 because PB didn’t have a subscription until then.</em></p>

<h3 id="users">Users</h3>

<p>My monthly active users for 2025 were pretty much identical to 2024; around <strong>42,000</strong>.</p>

<p><img src="/assets/post-images/2025/12/2025-maus.png" alt="Chart of monthly active users from 2021 to 2025" class="post-image post-image--no-shadow" />
<em>Monthly active users from 2021-2025</em></p>

<h2 id="goals-for-2026">Goals for 2026</h2>

<p>I’m setting some loose goals for the year ahead.</p>

<h3 id="finish-pb">‘Finish’ PB</h3>

<p>PB will never be fully finished, but I want to get to the stage where it’s a cohesive, well-made app that offers a clear value proposition. If you work out, then PB should have an obvious benefit for you.</p>

<p>I realise this is quite a subjective goal, but I think there’s value in setting it so I can make sure it’s something I keep in mind and build towards in 2026.</p>

<h3 id="reach-5k-mrr">Reach $5K MRR</h3>

<p>This is a tough goal – I’d need to increase MRR by 38% this year to meet it – but it would be an amazing milestone to hit.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><category term="year-in-review" /><summary type="html"><![CDATA[How 2025 went]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Making your iOS app more accessible with dynamic type</title><link href="https://codakuma.com/dynamic-type/" rel="alternate" type="text/html" title="Making your iOS app more accessible with dynamic type" /><published>2025-05-26T00:00:00+00:00</published><updated>2025-05-26T00:00:00+00:00</updated><id>https://codakuma.com/dynamic-type</id><content type="html" xml:base="https://codakuma.com/dynamic-type/"><![CDATA[<p>I recently attended an event at Apple London for <a href="https://accessibility.day">Global Accessibility Awareness Day</a>, which spurred me to make my app <a href="https://apps.apple.com/gb/app/personal-best-workouts/id1510256676">Personal Best</a> more accessible.</p>

<p>I’m still working on adding better VoiceOver support, but I’m happy to say that Personal Best now works great at all dynamic type sizes. This means that no matter what text size people use the app will be fully usable.</p>

<h2 id="dynamic-type-primer">Dynamic type primer</h2>

<p><a href="https://developer.apple.com/design/human-interface-guidelines/typography#Supporting-Dynamic-Type">Dynamic type</a> is an Apple feature for customising your text size. As of May 2025 there are twelve possible values: <strong>xSmall</strong>, <strong>small</strong>, <strong>medium</strong>, <strong>large</strong> <em>(default value)</em>, <strong>xLarge</strong>, <strong>xxLarge</strong>, <strong>xxxLarge</strong>, <strong>AX1</strong>, <strong>AX2</strong>, <strong>AX3</strong>, <strong>AX4</strong>, and <strong>AX5</strong>. The <em>AX</em> in the latter five sizes stands for <em>accessibility</em>.</p>

<p>In terms of dynamic type support, apps fall into one of three categories:</p>

<h3 id="1-unsupported">1. Unsupported</h3>

<p>Some apps don’t support dynamic type at all. The font is always the same size regardless of your dynamic type setting.</p>

<p>Below is the <a href="https://www.skyscanner.net/">Skyscanner</a> app with my font size at the <em>AX5</em> (highest) setting. You can see it’s stuck showing text at the default size because they’ve opted out of dynamic type entirely.</p>

<p><img src="/assets/post-images/2025/05/skyscanner.png" alt="Screenshot of Skyscanner" class="post-image post-image-small" /></p>

<h3 id="2-supported-but-broken">2. Supported but broken</h3>

<p>Some apps support dynamic type but haven’t been fully optimised, so some elements look broken or cut off. An example of this is the weightlifting app <a href="https://www.hevyapp.com/">Hevy</a> where a lot of text gets cut off.</p>

<p><img src="/assets/post-images/2025/05/hevy.png" alt="Screenshot of Hevy" class="post-image post-image-small" /></p>

<h3 id="3-fully-supported">3. Fully supported</h3>

<p>Finally, we have apps that work perfectly at all dynamic type sizes. The text grows and shrinks according to the user’s text size, and everything still looks and works great. An example of this is <a href="https://foodnoms.com/">Foodnoms</a>, my food tracking app of choice.</p>

<p><img src="/assets/post-images/2025/05/foodnoms.png" alt="Screenshot of Foodnoms" class="post-image post-image-small" /></p>

<h2 id="the-state-of-personal-best">The state of Personal Best</h2>

<p>Before starting this work Personal Best was in category #2. There were parts that looked ok, but a bunch of issues in almost every screen with text getting cut off or otherwise looking bad.</p>

<p>I want Personal Best to be a best-in-class iOS app, which means making it work great for <strong>everyone</strong>. Not only is this the right thing to do, it’s also a business opportunity. My <a href="https://dashboard.telemetrydeck.com/registration/organization?referralCode=50QE8PTHDMB1JL8B">TelemetryDeck</a> analytics indicates that very few of my users are using the <em>AX</em> text sizes, which could be an indication that I was leaving the needs of a significant section of the market unaddressed.</p>

<p><img src="/assets/post-images/2025/05/td-dynamic-type.png" alt="Screenshot of dynamic type settings from TelemetryDeck" class="post-image post-image-small" /></p>

<p>To fix the issues, I went through every screen and checked it at the <code class="language-plaintext highlighter-rouge">AX5</code> size, and every time something looked broken, I’d adapt the layout to fix it. Then I’d put the text back to the default size, and check I hadn’t inadvertently broken anything in the process.</p>

<h2 id="fixing-it">Fixing it</h2>

<p>I found that every issue fell into one of three categories:</p>

<ul>
  <li>Non-scrollable content</li>
  <li>Insufficient horizontal space</li>
  <li>Custom approach required</li>
</ul>

<p>Here’s how I approached each of these issues.</p>

<h3 id="non-scrollable-content">Non-scrollable content</h3>

<p>Often we’ll have some content that fits fine at smaller sizes, but at very large ones it goes off the screen. Unlike on the web, SwiftUI views don’t scroll by default when they overflow.</p>

<p><img src="/assets/post-images/2025/05/scrollable-0.png" alt="Screenshot showing the scrollable content issue" class="post-image" /></p>

<p>In the screenshot above, the text gets cut off and there’s no way for users to see the end of the text.</p>

<p>A simple fix is to wrap it all in a <code class="language-plaintext highlighter-rouge">ScrollView</code>. This solves the problem but replaces it <a href="https://youtu.be/EKWW6oFQDZY?si=owKFOMbh4FjlHSYv&amp;t=30">with a different problem</a>. We’ve broken the layout at non-AX type sizes – our content was bottom aligned but that’s no longer the case.</p>

<p><img src="/assets/post-images/2025/05/scrollable-1.png" alt="Screenshot showing wrapping it in a ScrollView" class="post-image post-image-small" /></p>

<p>To fix this, we can make the ScrollView only display when needed. For this I turned to <a href="https://github.com/dkk/ScrollViewIfNeeded">Daniel Klöck’s ScrollViewIfNeeded package</a>. This is a great utility which only adds a ScrollView if the content is large enough to need it. Here’s how the code looks:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">ScrollViewIfNeeded</span> <span class="p">{</span>
  <span class="kt">VStack</span> <span class="p">{</span>
    <span class="kt">Spacer</span><span class="p">()</span>
    <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"heart.fill"</span><span class="p">)</span>
    <span class="kt">Text</span><span class="p">(</span><span class="s">"Here is some long text content. At small text sizes it's not an issue, but at larger dynamic type sizes we may begin to run into some issues. Especially on smaller phones like iPhone SE. I'm running out of things to say."</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And here’s how it looks in practice:</p>

<p><img src="/assets/post-images/2025/05/scrollable-2.png" alt="Screenshot showing wrapping it in ScrollViewIfNeeded" class="post-image" /></p>

<h3 id="insufficient-horizontal-space">Insufficient horizontal space</h3>

<p>I use <code class="language-plaintext highlighter-rouge">HStack</code> extensively to lay content out side by side. At large sizes the available space is reduced because more of it is taken up by text, and the content can become difficult to parse. Here’s a typical example of this issue:</p>

<p><img src="/assets/post-images/2025/05/hstack-0.png" alt="Screenshot showing the layout issue" class="post-image" /></p>

<p>While it’s still pretty readable, it could be better. The icon on the trailing edge has a lot of empty space above and below it. If we give that space to the text, it won’t need to wrap as much. To do this, we change the layout at larger sizes to use a <code class="language-plaintext highlighter-rouge">VStack</code> instead of a <code class="language-plaintext highlighter-rouge">HStack</code>.</p>

<p>SwiftUI has a couple of ways to achieve this: <a href="https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-an-adaptive-layout-with-viewthatfits"><code class="language-plaintext highlighter-rouge">ViewThatFits</code></a> and <a href="https://developer.apple.com/documentation/swiftui/dynamictypesize/isaccessibilitysize"><code class="language-plaintext highlighter-rouge">dynamicType.isAccessibilitySize</code></a>. These allow us to detect if we’re running out of space or if the user is using an <code class="language-plaintext highlighter-rouge">AX</code> type size and show a different layout. Here’s how that looks:</p>

<h4 id="using-viewthatfits">Using ViewThatFits</h4>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">ViewThatFits</span> <span class="p">{</span>

  <span class="c1">// Original HStack-based layout</span>
  <span class="kt">HStack</span> <span class="p">{</span>
    <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span> <span class="p">{</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Welcome to the app!"</span><span class="p">)</span><span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title</span><span class="p">)</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Some extra text about onboarding."</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="kt">Spacer</span><span class="p">()</span>
    <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"globe"</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">imageScale</span><span class="p">(</span><span class="o">.</span><span class="n">large</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">tint</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="c1">// Alternative VStack-based layout</span>
  <span class="kt">VStack</span> <span class="p">{</span>
    <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span> <span class="p">{</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Welcome to the app!"</span><span class="p">)</span><span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title</span><span class="p">)</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Some extra text about onboarding."</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"globe"</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">imageScale</span><span class="p">(</span><span class="o">.</span><span class="n">large</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">tint</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
<span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="o">.</span><span class="n">regularMaterial</span><span class="p">)</span>
</code></pre></div></div>

<p>This works quite well. Our original HStack layout is used initially, but at larger sizes we get a VStack-based layout.</p>

<p><img src="/assets/post-images/2025/05/hstack-1.png" alt="Screenshot of the same view using ViewThatFits" class="post-image" /></p>

<p>This is a big improvement. Instead of eight lines the text now only occupies five lines, and <em>‘onboarding’</em> doesn’t need to be hyphenated; it fits on one line.</p>

<p>There is a potential issue however. The VStack-based layout kicks in at the <em>xLarge</em> size, when it probably didn’t need to. I’m not knowledgeable enough about SwiftUI’s layout system to know why this happens. In your case this might not be a problem, but for me I wanted to maintain the HStack-based layout until it didn’t make sense anymore. For this, I turned to <code class="language-plaintext highlighter-rouge">dynamicType.isAccessibilitySize</code>.</p>

<h4 id="using-dynamictypeisaccessibilitysize">Using dynamicType.isAccessibilitySize</h4>

<p>We can use SwiftUI’s environment to detect the current type size, which comes with a convenient property to check whether it’s one of the <code class="language-plaintext highlighter-rouge">AX</code> sizes. We can use this to only change to the VStack-based layout when the user is using an <em>AX</em> size:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Environment</span><span class="p">(\</span><span class="o">.</span><span class="n">dynamicTypeSize</span><span class="p">)</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">dynamicTypeSize</span>

<span class="o">...</span>

<span class="kt">Group</span> <span class="p">{</span>
  <span class="k">if</span> <span class="n">dynamicTypeSize</span><span class="o">.</span><span class="n">isAccessibilitySize</span> <span class="p">{</span>

    <span class="c1">// VStack-based layout</span>
    <span class="kt">VStack</span> <span class="p">{</span>
      <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">Text</span><span class="p">(</span><span class="s">"Welcome to the app!"</span><span class="p">)</span><span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title</span><span class="p">)</span>
        <span class="kt">Text</span><span class="p">(</span><span class="s">"Some extra text about onboarding."</span><span class="p">)</span>
          <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="p">)</span>
          <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
      <span class="p">}</span>
      <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"globe"</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">imageScale</span><span class="p">(</span><span class="o">.</span><span class="n">large</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">tint</span><span class="p">)</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="k">else</span> <span class="p">{</span>

    <span class="c1">// HStack-based layout</span>
    <span class="kt">HStack</span> <span class="p">{</span>
      <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">Text</span><span class="p">(</span><span class="s">"Welcome to the app!"</span><span class="p">)</span><span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title</span><span class="p">)</span>
        <span class="kt">Text</span><span class="p">(</span><span class="s">"Some extra text about onboarding."</span><span class="p">)</span>
          <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="p">)</span>
          <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
      <span class="p">}</span>
      <span class="kt">Spacer</span><span class="p">()</span>
      <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"globe"</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">imageScale</span><span class="p">(</span><span class="o">.</span><span class="n">large</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">tint</span><span class="p">)</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
<span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="o">.</span><span class="n">regularMaterial</span><span class="p">)</span>
</code></pre></div></div>

<p>This approach isn’t necessarily better than using <code class="language-plaintext highlighter-rouge">ViewThatFits</code>, it’s just different. You should choose whichever approach makes the most sense for the screen you’re adapting.</p>

<h4 id="going-the-extra-mile">Going the extra mile</h4>

<p>This works well, but we can enhance it a bit more by making the VStack-based layout look a bit more at home. It’s nice when the screen works <strong>fine</strong> at large type sizes, but we can go the extra mile by making it look <strong>great</strong>. Let’s now tweak the layout to look a bit nicer:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">dynamicTypeSize</span><span class="o">.</span><span class="n">isAccessibilitySize</span> <span class="p">{</span>
  <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">center</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"globe"</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">imageScale</span><span class="p">(</span><span class="o">.</span><span class="n">large</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">tint</span><span class="p">)</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Welcome to the app!"</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title</span><span class="p">)</span>
      <span class="o">.</span><span class="nf">multilineTextAlignment</span><span class="p">(</span><span class="o">.</span><span class="n">center</span><span class="p">)</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Some extra text about onboarding."</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">multilineTextAlignment</span><span class="p">(</span><span class="o">.</span><span class="n">center</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here I’ve removed a redundant second VStack, centre-aligned everything, and moved the image to the top of the view.</p>

<p><img src="/assets/post-images/2025/05/hstack-2.png" alt="Before and after screenshots of the view improvements" class="post-image" /></p>

<h4 id="cleaning-it-up">Cleaning it up</h4>

<p>There’s a lot of duplication in this code because we’re defining two completely different layouts. We can clean this up by making a new reusable view that abstracts away much of the logic for, named <code class="language-plaintext highlighter-rouge">HOrVStack</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">HOrVStack</span><span class="o">&lt;</span><span class="kt">Content</span><span class="p">:</span> <span class="kt">View</span><span class="o">&gt;</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
  <span class="kd">@Environment</span><span class="p">(\</span><span class="o">.</span><span class="n">dynamicTypeSize</span><span class="p">)</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">dynamicTypeSize</span>
  <span class="k">let</span> <span class="nv">content</span><span class="p">:</span> <span class="kt">Content</span>
  <span class="k">let</span> <span class="nv">flipAt</span><span class="p">:</span> <span class="kt">DynamicTypeSize</span>
  <span class="k">let</span> <span class="nv">horizontalAlignment</span><span class="p">:</span> <span class="kt">HorizontalAlignment</span>
  <span class="k">let</span> <span class="nv">verticalAlignment</span><span class="p">:</span> <span class="kt">VerticalAlignment</span>

  <span class="nf">init</span><span class="p">(</span>
    <span class="nv">flipAt</span><span class="p">:</span> <span class="kt">DynamicTypeSize</span> <span class="o">=</span> <span class="o">.</span><span class="n">accessibility1</span><span class="p">,</span>
    <span class="nv">horizontalAlignment</span><span class="p">:</span> <span class="kt">HorizontalAlignment</span> <span class="o">=</span> <span class="o">.</span><span class="n">center</span><span class="p">,</span>
    <span class="nv">verticalAlignment</span><span class="p">:</span> <span class="kt">VerticalAlignment</span> <span class="o">=</span> <span class="o">.</span><span class="n">center</span><span class="p">,</span>
    <span class="kd">@ViewBuilder</span> <span class="nv">content</span><span class="p">:</span> <span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">Content</span>
  <span class="p">)</span> <span class="p">{</span>
    <span class="k">self</span><span class="o">.</span><span class="n">content</span> <span class="o">=</span> <span class="nf">content</span><span class="p">()</span>
    <span class="k">self</span><span class="o">.</span><span class="n">flipAt</span> <span class="o">=</span> <span class="n">flipAt</span>
    <span class="k">self</span><span class="o">.</span><span class="n">horizontalAlignment</span> <span class="o">=</span> <span class="n">horizontalAlignment</span>
    <span class="k">self</span><span class="o">.</span><span class="n">verticalAlignment</span> <span class="o">=</span> <span class="n">verticalAlignment</span>
  <span class="p">}</span>

  <span class="k">var</span> <span class="nv">layout</span><span class="p">:</span> <span class="kt">AnyLayout</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">dynamicTypeSize</span> <span class="o">&lt;</span> <span class="n">flipAt</span> <span class="p">{</span>
      <span class="kt">AnyLayout</span><span class="p">(</span><span class="kt">HStackLayout</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="n">verticalAlignment</span><span class="p">))</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
      <span class="kt">AnyLayout</span><span class="p">(</span><span class="kt">VStackLayout</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="n">horizontalAlignment</span><span class="p">))</span>
    <span class="p">}</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="n">layout</span> <span class="p">{</span> <span class="n">content</span> <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h5 id="usage">Usage</h5>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="o">...</span>

<span class="k">var</span> <span class="nv">imageView</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
  <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"globe"</span><span class="p">)</span>
    <span class="o">.</span><span class="nf">imageScale</span><span class="p">(</span><span class="o">.</span><span class="n">large</span><span class="p">)</span>
    <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">tint</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">textAlignment</span><span class="p">:</span> <span class="kt">TextAlignment</span> <span class="p">{</span>
  <span class="k">return</span> <span class="n">dynamicTypeSize</span><span class="o">.</span><span class="n">isAccessibilitySize</span> <span class="p">?</span> <span class="o">.</span><span class="nv">center</span> <span class="p">:</span> <span class="o">.</span><span class="n">leading</span>
<span class="p">}</span>

<span class="kd">@ViewBuilder</span>
<span class="k">var</span> <span class="nv">hStackView</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
  <span class="kt">HOrVStack</span><span class="p">(</span><span class="nv">horizontalAlignment</span><span class="p">:</span> <span class="o">.</span><span class="n">center</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">dynamicTypeSize</span><span class="o">.</span><span class="n">isAccessibilitySize</span> <span class="p">{</span> <span class="n">imageView</span> <span class="p">}</span>
    <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span> <span class="p">{</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Welcome to the app!"</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">multilineTextAlignment</span><span class="p">(</span><span class="n">textAlignment</span><span class="p">)</span>
      <span class="kt">Text</span><span class="p">(</span><span class="s">"Some extra text about onboarding."</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">multilineTextAlignment</span><span class="p">(</span><span class="n">textAlignment</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">dynamicTypeSize</span><span class="o">.</span><span class="n">isAccessibilitySize</span> <span class="p">{</span>
      <span class="kt">Spacer</span><span class="p">()</span>
      <span class="n">imageView</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
  <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="o">.</span><span class="n">regularMaterial</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now we have both HStack and VStack-based layouts looking great with a minimal amount of duplication.</p>

<p>I used this pattern extensively to adapt Personal Best, and having a reusable view was a huge time saver.</p>

<h2 id="custom-approaches">Custom approaches</h2>

<p>Adapting Personal Best for dynamic type fell solidly into the <a href="https://en.wikipedia.org/wiki/Pareto_principle">80/20 rule</a>: 80% of issues were fixable in 20% of the time by applying the quick fixes detailed above. The remaining 80% of the time was spent fixing the 20% of screens that needed a more custom approach.</p>

<p>For these there’s often no ‘one size fits all’ solution; you need to carefully assess what doesn’t look right at large type sizes, then apply fixes on a case-by-case basis. Here are some examples from Personal Best.</p>

<h3 id="charts">Charts</h3>

<p>I had a chart that struggled at larger sizes because of a lack of horizontal space for the text labels. I improved it by changing the chart to use a vertical layout at larger type sizes.</p>

<p><img src="/assets/post-images/2025/05/weekday-chart.png" alt="Before and after screenshots of the chart improvements" class="post-image" /></p>

<h3 id="tables">Tables</h3>

<p>My paywall features a comparison of which features are available in the free and paid versions. At larger sizes there simply wasn’t enough horizontal space for this to look good, so I switched it to a simpler list of paid features at larger type sizes.</p>

<p><img src="/assets/post-images/2025/05/paywall.png" alt="Before and after screenshots of the paywall improvements" class="post-image" /></p>

<h3 id="background-contrast">Background contrast</h3>

<p>The onboarding screen has a graphic at the top of the screen, where various workout types move in concentric circles. At smaller type sizes this looks fine, but when the text is very large the text clashes with the background, making it difficult to read.</p>

<p>To fix this I added a background to the lower part of the screen which is only visible at <em>AX</em> text sizes. It’s a simple fix, but it goes a long way to make the screen more readable.</p>

<p><img src="/assets/post-images/2025/05/onboarding.png" alt="Before and after screenshots of the onboarding improvements" class="post-image" /></p>

<h2 id="takeaways">Takeaways</h2>

<p>Here’s some tips from my own journey into making Personal Best more accessible.</p>

<h3 id="1-do-it-from-the-start">1. Do it from the start</h3>

<p>I could have avoided all this work by just making these screens properly support dynamic type from the beginning. It takes about 10% more work to do this from the start, compared to the large effort it took for me to fix the whole app at a later date.</p>

<h3 id="2-keep-it-simple">2. Keep it simple</h3>

<p>If you fill your codebase with <code class="language-plaintext highlighter-rouge">if...else</code> statements for dynamic type support, you’re creating a lot of technical debt and making the codebase less maintainable in the future. Not only will this make work for future you, it’ll also increase the likelihood of you making a mistake and inadvertently breaking your app’s dynamic type support in the future.</p>

<p>Keep things simple by using reusable views as much as you can (like my <code class="language-plaintext highlighter-rouge">HOrVStack</code> view), and avoid duplicating your code.</p>

<h3 id="3-done-is-better-than-perfect">3. ‘Done’ is better than perfect</h3>

<p>We all want our apps to be the best they can be, but if you’re struggling to make something look absolutely perfect at large type sizes, just settle for making it usable. It’ll deliver value immediately for your users who use large type sizes, and you can improve it further at a later date.</p>

<h2 id="further-reading">Further reading</h2>

<p>If you’d like to learn more about dynamic type, here are some great resources that I found helpful.</p>

<ul>
  <li><a href="https://developer.apple.com/videos/play/wwdc2024/10074/">Apple: Get Started With Dynamic Type (WWDC 2024)</a></li>
  <li><a href="https://uxdesign.cc/designing-for-scalable-dynamic-type-in-ios-5d3e2ae554eb">Bang Tran: Designing for scalable Dynamic Type in iOS for accessibility</a></li>
  <li><a href="https://docs.deque.com/devtools-mobile/2023.8.16/en/supports-dynamic-type">Deque: Guide to Supporting Dynamic Type</a></li>
</ul>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><category term="swiftui" /><category term="accessibility" /><summary type="html"><![CDATA[How I made my app more accessible, and how you can too.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Simple A/B testing with TelemetryDeck</title><link href="https://codakuma.com/ab-testing/" rel="alternate" type="text/html" title="Simple A/B testing with TelemetryDeck" /><published>2025-04-30T00:00:00+00:00</published><updated>2025-04-30T00:00:00+00:00</updated><id>https://codakuma.com/ab-testing</id><content type="html" xml:base="https://codakuma.com/ab-testing/"><![CDATA[<p>I’m working on growing my workout-tracking app <a href="https://apps.apple.com/gb/app/personal-best-workouts/id1510256676">Personal Best</a>, and as part of that I wanted to run an <a href="https://en.wikipedia.org/wiki/A/B_testing">A/B test</a> on two different paywall designs to see which one performed best. I’ve never done this in my apps before, and I was unsure how. I like to keep third-party libraries in my apps to a minimum, so I was reluctant to use something like <a href="https://mixpanel.com">Mixpanel</a> if I didn’t absolutely have to.</p>

<p>Fortunately I learned that <a href="https://dashboard.telemetrydeck.com/registration/organization?referralCode=50QE8PTHDMB1JL8B">TelemetryDeck</a> supports A/B testing. I’ve been a happy customer of TelemetryDeck for years. I use it to record analytics in my apps in a privacy-first way, so I can see things like daily active users, what OS people are using, and things like that.</p>

<p><img src="/assets/post-images/telemetry-deck-example.png" alt="Example of TelemetryDeck" class="post-image" /></p>

<p>So, I built a simple integration into my app to allow it to support A/B testing. It’s something manual and hard coded, but it suits my needs for running a basic experiment. Here’s how to do it.</p>

<h2 id="getting-started">Getting started</h2>

<p>Assuming you already have <a href="https://telemetrydeck.com/docs/guides/swift-setup">TelemetryDeck set up in your app</a>, you’ll need something in to represent each cohort. For this I made an enum.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>

<span class="kd">enum</span> <span class="kt">ABTestCohort</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
  <span class="k">case</span> <span class="n">control</span>
  <span class="k">case</span> <span class="n">experiment</span>

  <span class="kd">private</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">getRandomCohort</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">ABTestCohort</span> <span class="p">{</span>
    <span class="k">return</span> <span class="kt">Bool</span><span class="o">.</span><span class="nf">random</span><span class="p">()</span> <span class="p">?</span> <span class="o">.</span><span class="nv">control</span> <span class="p">:</span> <span class="o">.</span><span class="n">experiment</span>
  <span class="p">}</span>

  <span class="kd">static</span> <span class="kd">func</span> <span class="nf">getCohort</span><span class="p">(</span><span class="n">forKey</span> <span class="nv">key</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">ABTestCohort</span> <span class="p">{</span>

    <span class="k">if</span> <span class="k">let</span> <span class="nv">savedCohort</span> <span class="o">=</span> <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">string</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">),</span>
       <span class="k">let</span> <span class="nv">cohort</span> <span class="o">=</span> <span class="kt">ABTestCohort</span><span class="p">(</span><span class="nv">rawValue</span><span class="p">:</span> <span class="n">savedCohort</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">print</span><span class="p">(</span><span class="s">"🧪 Retrieved saved cohort '</span><span class="se">\(</span><span class="n">cohort</span><span class="o">.</span><span class="n">rawValue</span><span class="se">)</span><span class="s">' for experiment </span><span class="se">\(</span><span class="n">key</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
      <span class="k">return</span> <span class="n">cohort</span>
    <span class="p">}</span>

    <span class="c1">// If no cohort is saved, generate a random one and save it</span>
    <span class="k">let</span> <span class="nv">newCohort</span> <span class="o">=</span> <span class="nf">getRandomCohort</span><span class="p">()</span>
    <span class="nf">setCohort</span><span class="p">(</span><span class="n">newCohort</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="s">"🧪 Assigned cohort '</span><span class="se">\(</span><span class="n">newCohort</span><span class="o">.</span><span class="n">rawValue</span><span class="se">)</span><span class="s">' for experiment </span><span class="se">\(</span><span class="n">key</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">newCohort</span>
  <span class="p">}</span>

  <span class="kd">private</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">setCohort</span><span class="p">(</span><span class="n">_</span> <span class="nv">cohort</span><span class="p">:</span> <span class="kt">ABTestCohort</span><span class="p">,</span> <span class="n">forKey</span> <span class="nv">key</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">cohort</span><span class="o">.</span><span class="n">rawValue</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The code here is very straightforward. There’s only one public method: <code class="language-plaintext highlighter-rouge">getCohort(forKey:)</code>. It takes a key which corresponds to an experiment, then returns either <code class="language-plaintext highlighter-rouge">.control</code> or <code class="language-plaintext highlighter-rouge">.experiment</code> to denote which user the cohort is in. It makes use of <code class="language-plaintext highlighter-rouge">UserDefaults</code> to store the value locally, so that we’ll always get the same value each time.</p>

<h2 id="hooking-it-up-to-a-view">Hooking it up to a view</h2>

<p>Next we need to make use of it in a view. For this, let’s assume we have two SwiftUI views defined ahead of time: <code class="language-plaintext highlighter-rouge">Paywall_Control</code> and <code class="language-plaintext highlighter-rouge">Paywall_Experiment</code>. Now, we make a wrapper component that makes use of our new <code class="language-plaintext highlighter-rouge">ABTestCohort</code> enum and shows either the <em>control</em> or <em>experiment</em> to the user based on what it returns:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">SwiftUI</span>

<span class="kd">struct</span> <span class="kt">Paywall</span> <span class="p">{</span>
  <span class="kd">private</span> <span class="k">let</span> <span class="nv">cohort</span><span class="p">:</span> <span class="kt">ABTestCohort</span>

  <span class="nf">init</span><span class="p">()</span> <span class="p">{</span>
   <span class="k">self</span><span class="o">.</span><span class="n">cohort</span> <span class="o">=</span> <span class="kt">ABTestCohort</span><span class="o">.</span><span class="nf">getCohort</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"PaywallExperiment"</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="cp">#if DEBUG</span>
  <span class="nf">init</span><span class="p">(</span><span class="nv">cohort</span><span class="p">:</span> <span class="kt">ABTestCohort</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">self</span><span class="o">.</span><span class="n">cohort</span> <span class="o">=</span> <span class="n">cohort</span>
  <span class="p">}</span>
  <span class="cp">#endif</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="k">switch</span> <span class="n">cohort</span> <span class="p">{</span>
      <span class="k">case</span> <span class="o">.</span><span class="nv">control</span><span class="p">:</span> <span class="kt">Paywall_Control</span><span class="p">()</span>
      <span class="k">case</span> <span class="o">.</span><span class="nv">experiment</span><span class="p">:</span> <span class="kt">Paywall_Experiment</span><span class="p">()</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="cp">#Preview("Control") {</span>
  <span class="kt">Paywall</span><span class="p">(</span><span class="nv">cohort</span><span class="p">:</span> <span class="o">.</span><span class="n">control</span><span class="p">)</span>
<span class="p">}</span>

<span class="cp">#Preview("Experiment") {</span>
  <span class="kt">Paywall</span><span class="p">(</span><span class="nv">cohort</span><span class="p">:</span> <span class="o">.</span><span class="n">experiment</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Just like before the code here is very simple. When the view initialises, we determine which cohort the user is in, and set a local variable accordingly. Then we display one of the views based on what we get back.</p>

<p>By doing this on <code class="language-plaintext highlighter-rouge">init</code> instead of asynchronously using some state, we ensure that a value is set before the view appears. It’s ok to do this here as the way the value gets set – being read from <code class="language-plaintext highlighter-rouge">UserDefaults</code> – is very fast, but if we were doing a more complex lookup (for example, getting a value from the internet), we’d do something more fault tolerant.</p>

<p>The <code class="language-plaintext highlighter-rouge">#if DEBUG</code> part gives us an extra initialiser that’s only available in debug mode, allowing us to manually test this in previews. It’ll be stripped out in release builds.</p>

<h2 id="sending-it-to-telemetrydeck">Sending it to TelemetryDeck</h2>

<p>Now we need to let TelemetryDeck know when each variant is displayed. All we need to do for this is add an <code class="language-plaintext highlighter-rouge">onAppear</code> modifier to our view’s <code class="language-plaintext highlighter-rouge">body</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">...</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">Group</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="n">cohort</span> <span class="p">{</span>
      <span class="k">case</span> <span class="o">.</span><span class="nv">control</span><span class="p">:</span> <span class="kt">Paywall_Control</span><span class="p">()</span>
      <span class="k">case</span> <span class="o">.</span><span class="nv">experiment</span><span class="p">:</span> <span class="kt">Paywall_Experiment</span><span class="p">()</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="o">.</span><span class="n">onAppear</span> <span class="p">{</span>
    <span class="kt">TelemetryDeck</span><span class="o">.</span><span class="nf">signal</span><span class="p">(</span><span class="s">"PaywallShown"</span><span class="p">,</span> <span class="nv">parameters</span><span class="p">:</span> <span class="p">[</span><span class="s">"variant"</span><span class="p">:</span> <span class="n">cohort</span><span class="o">.</span><span class="n">rawValue</span><span class="p">])</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here we’re simply sending a <code class="language-plaintext highlighter-rouge">PaywallShown</code> event to TelemetryDeck when the view loads, with some metadata to tell it which variant we displayed.</p>

<h2 id="creating-an-ab-test-in-telemetrydeck">Creating an A/B test in TelemetryDeck</h2>

<p>The last step is to set up an A/B test on TelemetryDeck. Create a new insight, and when asked for the type, choose <strong>A/B Testing Experiment</strong>.</p>

<p>For <strong>sample1</strong> add the event we sent in the previous step, with the variant set to <code class="language-plaintext highlighter-rouge">control</code>. For <strong>sample2</strong>, do the same event, with the variant set to <code class="language-plaintext highlighter-rouge">experiment</code>.</p>

<p>Adding the <strong>successCriterion</strong> is the final step. This is how we tell TelemetryDeck the thing we’re trying to improve. In my case, that’s people purchasing Personal Best Pro. I use TelemetryDeck’s <a href="https://telemetrydeck.com/docs/integrations/revenuecat/">integration with RevenueCat</a>, which means there’s already an event for this I can hook into: <code class="language-plaintext highlighter-rouge">RevenueCat.Events.INITIAL_PURCHASE</code>.</p>

<p>And with that, the A/B test is set up. TelemetryDeck will monitor the events my app sends and determine which variant is more successful.</p>

<p><img src="/assets/post-images/ab-test-setup.png" alt="TelemetryDeck A/B test setup" class="post-image" /></p>

<h2 id="issues-and-future-improvements">Issues and future improvements</h2>

<p>This is a very lightweight version of A/B testing, and it does have its limitations.</p>

<h3 id="1-only-one-experiment">1. Only one experiment</h3>

<p>This approach only allows us to test one experiment at a time. If we wanted to test three different paywalls to see which is best, we’d need to adapt the code to support this.</p>

<h3 id="2-it-affects-all-users">2. It affects all users</h3>

<p>If we wanted to tweak the test to not be 50/50 (for example only showing the experiment to 10% of users), we can’t easily do that without changing the code.</p>

<h3 id="3-no-remote-control">3. No remote control</h3>

<p>There’s no way for us to remotely enable or disable the test. It’s baked into the app and we can only remove it with an App Store update, which could take days to get approved.</p>

<p>Fixing this would complicate our simple solution somewhat. We’d probably need <em>some sort of</em> server-side logic that the app can check on startup which says if the experiment is enabled. A lightweight version might be a <a href="https://workers.cloudflare.com">Cloudflare worker</a> which returns some JSON, like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"tests"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"paywall"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"someOtherTest"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then during the app’s startup you’d download this JSON and use it in the UI to determine which A/B tests should be enabled.</p>

<h2 id="finally">Finally</h2>

<p>So that’s how to run a lightweight A/B test using TelemetryDeck. If you’d like to try it for yourself, <a href="https://dashboard.telemetrydeck.com/registration/organization?referralCode=50QE8PTHDMB1JL8B">here’s my referral link for TelemetryDeck</a>, which will give you 100,000 extra signals each month for free.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><summary type="html"><![CDATA[How to use TelemetryDeck to run a simple A/B test on your app]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">2024 in review</title><link href="https://codakuma.com/2024-in-review/" rel="alternate" type="text/html" title="2024 in review" /><published>2024-12-30T00:00:00+00:00</published><updated>2024-12-30T00:00:00+00:00</updated><id>https://codakuma.com/2024-in-review</id><content type="html" xml:base="https://codakuma.com/2024-in-review/"><![CDATA[<p>In what’s now become a yearly tradition, here’s my end of year review for 2024. I’ll talk about how my apps performed, what I learned, and what I’m looking forward to in 2025.</p>

<p>If you’d like to catch up on previous years first, check out <a href="/2023-in-review/">2023</a>, <a href="/im-feeling-22/">2022</a>, <a href="/2021-in-review/">2021</a>, and <a href="/2020-in-review/">2020</a>.</p>

<h2 id="overall">Overall</h2>

<p>I set myself three goals for 2024:</p>

<h3 id="ship-two-new-apps">Ship two new apps</h3>

<p><strong>Status:</strong> Achieved ✅</p>

<p>I actually ended up shipping <strong>three</strong> new apps this year.</p>

<p>In February I released <a href="https://apps.apple.com/app/salarypig/id6475237479">SalaryPig</a>, an app for tracking your salary. It’s pretty niche, but in December 2023 I started a new job and absolutely hated it from day one. I made SalaryPig as a way to distract myself from it. You’ll be pleased to read that I’ve since left that job and I now work somewhere I’m much happier at.</p>

<ul>
  <li><a href="/introducing-salarypig/">Read SalaryPig’s introduction post</a></li>
  <li><a href="https://apps.apple.com/us/app/salarypig/id6475237479">Get SalaryPig on the App Store</a></li>
</ul>

<p>Later in the year I followed this up with two new apps, both simple quiz apps. My app <a href="https://taylorsversion.app">Taylor’s Version</a> already contained a quiz, and my friend <a href="https://oval.fi/">Rauno</a> suggested I split it into a separate app. A few months after that I made a second quiz app, this time about football.</p>

<p>To make it easier to make more quiz apps, I created a private framework named QuizKit. The idea was that QuizKit would handle 95% of the logic, so all I’d need to do is customise the questions and some theming for each app, allowing me to quickly make lots of apps like this. If each made a small amount of revenue, they could add up to make a good income.</p>

<p>Unfortunately, with both quiz apps I had a lot of issues getting them through Apple’s App Review team. It was enough of a hassle to put me off from making any more, so I decided to leave it at just these two quizzes for now.</p>

<ul>
  <li><a href="/new-app-experiment/">Read Taylor Swift Quiz’s introduction post</a></li>
  <li>
    <p><a href="https://apps.apple.com/app/id6563151175">Get Taylor Swift Quiz on the App Store</a></p>
  </li>
  <li><a href="/new-app-experiment-evolved/">Read Football Quiz’s introduction post</a></li>
  <li><a href="https://apps.apple.com/app/id6563151175">Get Football Quizzes on the App Store</a></li>
</ul>

<h3 id="add-goal-tracking-to-personal-best">Add goal tracking to Personal Best</h3>

<p><strong>Status:</strong> Missed 😭</p>

<p>Not much to say here, I just didn’t have time, and prioritised some other things instead. I’m hoping to get to it in 2025…</p>

<h3 id="use-an-apple-vision-pro">Use an Apple Vision Pro</h3>

<p><strong>Status:</strong> Achieved ✅</p>

<p>A bit of a frivolous goal, but I was fortunate to attend one of Apple’s Vision Pro developer workshops this year. It was great to get some hands on time with the headset, and I was able to ship a VisionOS version of SalaryPig as a result.</p>

<h2 id="getting-featured">Getting featured</h2>

<p>This year something amazing happened – Personal Best was featured as App of the Day on the App Store in over 150 regions! It was incredible to be recognised and I still can’t actually believe it happened! As always, I’m grateful to the App Store editorial team for promoting my work ❤️</p>

<p><img src="/assets/post-images/2024-aotd.png" alt="App of the Day screenshots" class="post-image post-image--no-shadow" /></p>

<h2 id="delivery">Delivery</h2>

<p>Outside of the other apps I mentioned above, I shipped a lot of improvements to Personal Best, my workout app which I consider to be my ‘main’ app. The improvements were more <em>transitional</em> than feature based, with a lot of behind-the-scenes work focused on improving the app’s foundations for the future.</p>

<h3 id="pace-customisation">Pace customisation</h3>

<p>Up to now, pace was always expressed in terms of seconds per mile or kilometre, based on your distance setting. This makes sense for most workouts, but not for things like swimming where you’d typically measure pace in other ways, like <em>seconds per 100m</em> or <em>seconds per 25yds</em>. To fix this, I made how you view pace completely customisable for each type of workout.</p>

<p>The default options are sensible so most users won’t need to change them, but for those who want more customisation there’s now full flexibility to see pace however makes the most sense to you.</p>

<p><img src="/assets/post-images/2024-pace.png" alt="Pace customisation" class="post-image post-image-small post-image--no-shadow" /></p>

<h3 id="all-new-leaderboards">All-new leaderboards</h3>

<p>Leaderboards are one of the most-loved features in Personal Best, but they were also one of the oldest parts of the codebase, from back in May 2020. I’d often get requests for improvements, but due to some poor decisions I made with the Core Data schema back then, I wasn’t able to easily deliver them.</p>

<p>So, I completely rewrote leaderboards from scratch using Swift Data, with a much more flexible schema to allow for more customisation. I was pretty nervous about shipping this, in case the migration script didn’t work, but after many, many hours of thorough testing, I released it in November and there have been zero issues so far.</p>

<p><img src="/assets/post-images/2024-leaderboards.png" alt="All-new leaderboards" class="post-image post-image-small post-image--no-shadow" /></p>

<h3 id="improved-help">Improved help</h3>

<p>I added a new <strong>Help and FAQs</strong> section to the app, with answers to common queries. I’ve maintained an FAQs page on Personal Best’s website for a few years, but I noticed that I was receiving a lot of emails from people asking things that were already covered in the FAQs. To improve this, I built the FAQs into the app directly to make it easier for people to find answers.</p>

<p><img src="/assets/post-images/2024-help.png" alt="Help" class="post-image post-image-small post-image--no-shadow" /></p>

<h3 id="price-experiments">Price experiments</h3>

<p>I changed some of the plumbing inside Personal Best to allow me to make use of <a href="https://www.revenuecat.com/docs/tools/experiments-v1/experiments-overview-v1">RevenueCat’s Experiments feature</a>, to experiment with pricing and paywall variations.</p>

<h3 id="hiding-facts">Hiding facts</h3>

<p>I added the ability to hide facts from your workout stats, for people who don’t want to see them for whatever reason. This was driven by a request from somebody who didn’t want to see food-based facts, but I extended it to work on all categories.</p>

<p><img src="/assets/post-images/2024-facts.png" alt="Facts" class="post-image post-image-small post-image--no-shadow" /></p>

<h2 id="numbers">Numbers</h2>

<p>So, how did I do for revenue this year? Last year saw <a href="/2023-in-review/">an enormous increase</a> in downloads and revenue, and going into this year I was a mixture of excited about growing it further, and nervous about maintaining it.</p>

<h3 id="downloads">Downloads</h3>

<p>Personal Best’s growth continued this year, which is amazing to see. It went from <strong>87,000</strong> downloads in 2023 to <strong>180,000</strong> this year, which is just over double.</p>

<p>Taylor’s Version’s downloads dropped from <strong>13,000</strong> in 2023 to <strong>5,500</strong> in 2024. This makes sense, as this app is only used for replacing Taylor Swift songs in your playlists, and no new <em>Taylor’s Version</em> albums came out this year.</p>

<p>SalaryPig did ok in its debut year, with just over <strong>1,000</strong> downloads. I’d have loved to see more, but given how niche it is, it’s understandable.</p>

<p>My Taylor Swift quiz got just under <strong>3,000</strong> downloads, which feels pretty good for an app I built in a weekend and did no marketing for.</p>

<p>Finally, my poor, unloved Football Quiz app had just <strong>169</strong> downloads this year.</p>

<p><img src="/assets/post-images/2024-downloads.png" alt="Visualisation of download numbers" class="post-image post-image--no-shadow" /></p>

<h3 id="sales">Sales</h3>

<p>Sales were great this year, once again driven by Personal Best. It accounted for <strong>$35,000</strong> of revenue, roughly a 2x increase on 2023.</p>

<p>My other apps had very minimal sales: Taylor Swift Quiz made <strong>$330</strong>, SalaryPig <strong>$200</strong>, Taylor’s Version <strong>$179</strong>, and in last place Football Quiz made just <strong>$4</strong>.</p>

<p><img src="/assets/post-images/2024-sales.png" alt="Visualisation of sales" class="post-image post-image--no-shadow" /></p>

<h3 id="users">Users</h3>

<p>I use <a href="https://dashboard.telemetrydeck.com/registration/organization?referralCode=50QE8PTHDMB1JL8B">TelemetryDeck</a> to get privacy-first analytics in my apps.</p>

<p>I only track Personal Best’s active users as it’s the app I’m mostly focused on growing, and it had a healthy increase this year, with <strong>43,000</strong> monthly active users, up from <strong>28,000</strong> in 2023.</p>

<p><img src="/assets/post-images/2024-active-users.png" alt="Visualisation of Personal Best's monthly active users" class="post-image post-image--no-shadow" /></p>

<h2 id="goals-for-2025">Goals for 2025</h2>

<p>So what’s next? I’m setting myself some loose goals for the coming year. As with last year I’m not setting any revenue-based goals.</p>

<h3 id="focus-on-personal-best">Focus on Personal Best</h3>

<p>If the graphs above show one thing, it’s that Personal Best deserves to be my main focus. I think it has huge growth potential to become even bigger if I put more focus into making it a best-in-class iOS app. I’m terrible for getting distracted and building other apps, but this year I intend to focus <strong>only</strong> on improving Personal Best.</p>

<p>I have a day job that I love, but it does mean that I’m often not able to spend as much time on my apps as I want. Next year I’m going to try and block out specific evenings in my calendar to work on Personal Best, to see if that will help me balance my time better.</p>

<h3 id="build-goal-tracking">Build goal tracking</h3>

<p>I’m carrying over this goal from last year into 2025, as I really need to get to it eventually, and I think it has the potential to be a game-changing feature.</p>

<h3 id="continue-refactoring">Continue refactoring</h3>

<p>I’ve been improving a lot of Personal Best’s codebase behind the scenes, like switching from callback-based APIs to async/await ones, and I want to do that along with keep working on improving performance.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><category term="year-in-review" /><summary type="html"><![CDATA[How 2024 went for me and my apps]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Expanding my new app experiment</title><link href="https://codakuma.com/new-app-experiment-evolved/" rel="alternate" type="text/html" title="Expanding my new app experiment" /><published>2024-07-28T00:00:00+00:00</published><updated>2024-07-28T00:00:00+00:00</updated><id>https://codakuma.com/football-quiz</id><content type="html" xml:base="https://codakuma.com/new-app-experiment-evolved/"><![CDATA[<p>Four months ago <a href="/new-app-experiment">I wrote about</a> a <a href="https://apps.apple.com/app/id6479753779">Taylor Swift trivia app</a> I’d made as an experiment. The idea was to spend a small amount of time building a quiz app and seeing how it performs. At the time, I wrote <em>“If it does well, it’ll be very easy for me adapt it to make more trivia apps about other topics (e.g. a football quiz, movies quiz, and so on).”</em></p>

<p>It’s doing well, so I’ve adapted it into <a href="https://apps.apple.com/app/id6563151175">a football quiz app</a>.</p>

<h2 id="how-well-is-doing-well">How well is ‘doing well’</h2>

<p>The Taylor Swift quiz has been on the App Store just shy of four months, getting just under 2,000 downloads and $220 in revenue. Compared to my main app <a href="https://getpersonalbest.com">Personal Best</a> this is a drop in the bucket, but I think it’s enough proof that it’s a viable avenue to keep exploring.</p>

<p>So, this felt like the right time to expand the experiment with a second quiz. I’m a big football* fan, so a football quiz was a natural choice.</p>

<p>*soccer for Americans</p>

<h2 id="building-it">Building it</h2>

<p>Regular readers will know that each time I make a new app I try to learn something new (for example, with <a href="https://apps.apple.com/us/app/salarypig/id6475237479">SalaryPig</a> I learned how to make interactive 2D graphics from shapes, as well as syncing user preferences over iCloud).</p>

<p>For this app, I learned how to make and maintain my own <a href="https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode">Swift package</a>. I abstracted most of the logic from my existing app into a package I named <strong>QuizKit</strong>, which then meant the new app could just be a light layer on top of that, with about 95% of functionality coming from QuizKit and only the actual questions and styling bespoke to the app.</p>

<p>Thanks to this, I was able to make the new app in less than a day. The most time-consuming parts were the things I couldn’t abstract away, like actually writing the quiz questions and setting up the in-app purchases.</p>

<h2 id="the-finished-product">The finished product</h2>

<p>Football Quizzes is now <a href="https://apps.apple.com/app/id6563151175">live on the App Store</a>. It supports iPhone and iPad, as well as macOS and visionOS via the iPad app compatibility mode.</p>

<p><img src="/assets/post-images/fbq-screenshots.png" alt="Screenshots of Football Quizzes" class="post-image" /></p>

<h2 id="whats-next">What’s next</h2>

<p>Actually getting the app released was really difficult, as the App Review team at Apple rejected it repeatedly. Firstly because it fell afoul of <a href="https://developer.apple.com/app-store/review/guidelines/#spam">guideline 4.3</a>, as they argued it was too similar to my previous app and could be construed as a spam app built from a template. I managed to convince them by explaining that I’d put a lot of care into writing the questions and pointed out the high-quality UX, and contrasted it with some other low-quality quiz apps on the App Store.</p>

<p>Then, I had several more rejections for using intellectual property from FIFA. This is understandable – Apple don’t want to make themselves legally liable for anything on their store – so I had to remove all references to anything trademarked. For example, the “World Cup” quiz became the “World Championships” quiz. It’s a bit like Pro Evolution Soccer on PS2 where they didn’t have the rights to use real team names, so they had “West Midlands Village” instead of “Aston Villa”, “Man Blue” instead of “Manchester City”, and so on.</p>

<p>However, after this experience I’m going to put the quiz apps aside for now and dedicate 100% of my app development time to <a href="https://getpersonalbest.com">Personal Best</a>. It’s my biggest app by far, and I love building it and hearing from users who are enjoying it.</p>

<p>Watch this space for more updates on Personal Best and how this new quiz app performs.</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><summary type="html"><![CDATA[Expanding my trivia app]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Experimenting with a new app</title><link href="https://codakuma.com/new-app-experiment/" rel="alternate" type="text/html" title="Experimenting with a new app" /><published>2024-03-31T00:00:00+00:00</published><updated>2024-03-31T00:00:00+00:00</updated><id>https://codakuma.com/new-app-experiment</id><content type="html" xml:base="https://codakuma.com/new-app-experiment/"><![CDATA[<p>In my quest for more revenue from my app business, I’ve made a new app: a <a href="https://apps.apple.com/app/id6479753779">Taylor Swift quiz</a>.</p>

<h2 id="why">Why</h2>

<p>My app <a href="https://taylorsversion.app">Taylor’s Version</a> already features a small quiz that appears while it makes a slow request to an external API. Recently after talking to a friend I had the idea to split this into its own app.</p>

<p>There are a few apps like this already, but they often have really poor UX, huge download sizes, and just generally don’t come across great. I saw this as an opportunity for me to build a compelling experience, as well as a way for me to continue developing my design skills by tackling a type of app I haven’t really done before.</p>

<h2 id="how">How</h2>

<p>I’m wary of spreading myself too thin and taking focus from my main app <a href="https://getpersonalbest.com">Personal Best</a>, so I set myself some constraints:</p>

<h3 id="ready-in-one-weekend">Ready in one weekend</h3>

<p>I would spend exactly one weekend on the app, so that I didn’t end up sinking weeks into it.</p>

<h3 id="no-scope-creep">No scope creep</h3>

<p>I’m terrible for increasing the scope of my apps. My “it’ll just take a weekend” projects often grow into month-long sagas. To help me keep things lean, I defined the scope ahead of time and didn’t allow it to budge. The app needed to:</p>

<ul>
  <li>Offer a range of quizzes for people to complete. Some of them would be free, with others unlockable via in-app purchase.</li>
  <li>Have a nice UX that would make it stand out amongst other apps. I wanted each quiz to have its own colour scheme so they’d feel distinct from each other, and I wanted to have slick animations and haptics throughout. If Apple themselves made a trivia app, it might look something like this.</li>
  <li>Allow people to share images of their results, as this could potentially help growth.</li>
  <li>Support iPhone and iPad.</li>
</ul>

<h3 id="learn-something-new">Learn something new</h3>

<p>Each time I make a new app, I try to do something I’ve never done before as a way of learning something I can then apply to other apps. For example when I built <a href="/pig">SalaryPig</a> I learned how to make interactive, animated 2D graphics.</p>

<p>For this, I chose <a href="https://developer.apple.com/xcode/swiftdata/">Swift Data</a> as the new thing I’d learn. I want to use it on Personal Best in the future, so this would be a good introduction to it inside a real production app.</p>

<h2 id="the-finished-product">The finished product</h2>

<p>The app is now <a href="https://apps.apple.com/app/id6479753779">live on the App Store</a>. It supports iPhone and iPad, as well as macOS and visionOS via the iPad app compatibility mode.</p>

<p><img src="/assets/post-images/tsquiz-screenshots.png" alt="Screenshots of Quiz for Taylor Swift" class="post-image" /></p>

<h2 id="next-steps">Next steps</h2>

<p>I intend to let the app <a href="https://www.youtube.com/watch?v=qsUK-BG5OQQ">breathe</a> and see how it performs before doing more with it. My hope is that it’ll pick up some downloads over time via word of mouth and having good <a href="https://www.adjust.com/glossary/aso/">ASO</a></p>

<p>If it does well, it’ll be very easy for me adapt it to make more trivia apps about other topics (e.g. a football quiz, movies quiz, and so on).</p>

<p>There’s also some more roads I could go down if I decide to further develop this app, like:</p>

<h3 id="support-more-platforms">Support more platforms</h3>

<p>I’ve never made an Android app, and this could be a good way to get started with that. It could also translate well to tvOS, or even Apple Watch so people can play on the go.</p>

<h3 id="ads">Ads</h3>

<p>I’ve never included ads in an app, but if this does sufficiently good numbers it’ll be worth considering.</p>

<h3 id="stats-and-achievements">Stats and achievements</h3>

<p>It’d be fairly simple to add things like this too, and I could even add support for Game Centre while I’m at it.</p>

<h3 id="more-purchase-options">More purchase options</h3>

<p>Right now, each premium quiz costs 49p. I could offer more diverse pricing, like a bundle where you get 5 quizzes of your choice for a discount, or a ‘mega’ purchase to unlock everything.</p>

<h2 id="finally">Finally</h2>

<p>I’ll be sure to post an update in the future about how it’s performing. In the meantime, one of my <a href="/2023-in-review/">goals for 2024</a> was ship two new apps. With this and <a href="/introducing-salarypig/">SalaryPig</a>, mission accomplished 😎</p>]]></content><author><name>{&quot;twitter&quot;=&gt;&quot;shauneba&quot;}</name></author><category term="ios" /><summary type="html"><![CDATA[I've built a trivia app as an experiment]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://codakuma.com/assets/codakuma-logo.png" /><media:content medium="image" url="https://codakuma.com/assets/codakuma-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>