<?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://alpayc.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://alpayc.com/" rel="alternate" type="text/html" /><updated>2026-08-31T12:18:16+00:00</updated><id>https://alpayc.com/feed.xml</id><title type="html">Alpay Calalli</title><subtitle>A blog about Swift, iOS Development, and more</subtitle><author><name>Alpay Calalli</name></author><entry><title type="html">How to protect your users from Scam Coaching in your iOS App with Trust Insights</title><link href="https://alpayc.com/blog/2026/08/31/Trust-Insight-guide" rel="alternate" type="text/html" title="How to protect your users from Scam Coaching in your iOS App with Trust Insights" /><published>2026-08-31T13:00:00+00:00</published><updated>2026-08-31T13:00:00+00:00</updated><id>https://alpayc.com/blog/2026/08/31/Trust-Insight-guide</id><content type="html" xml:base="https://alpayc.com/blog/2026/08/31/Trust-Insight-guide"><![CDATA[<p>“The Trust Insights framework enables your app to request an evaluation, or insight, to help detect and respond to social engineering threats people may face.” as the official <a href="https://developer.apple.com/documentation/trustinsights">TrustInsight</a> documentation says.</p>

<p>So what does it really mean and how does it work? These were the questions that popped out to my mind, when I saw about this in WWDC26.</p>

<p>Social threats don’t always happen when someone on the other side of the world steals your password and takes control of your account. It also commonly happens with manipulation, the person you speak to on the phone or on the street misidentifies himself and pushes you to do actions such as loging-in to your account, sending money to an account, etc. Since, YOU are the one doing the operation, no security protections like 2FA will be help in these scenarios. From the moment our imaginary victim “Bill” also gives the 2FA code to the other person who is guiding him, done, there is nothing to do about it.</p>

<p>Well, what can you do as an Apple developer to prevent these on the client side? This is what TrustInsight is for. It “magically”(of course not magically, will come to this later in the article) finds these potential anomalies, coercive activies based on the context you give and returns the result to you to take an appropriate action, such as adding friction, requiring additional verification, or notifying the user about it. BUT, Apple does not recommend completely blocking the operation based solely on a trust insight.</p>

<p>Note: Note that this evalutation process may take a few seconds and requires an internet connection, so <span style="text-decoration: underline double;">when</span> you use it is also important for user experience.</p>

<p>In this article, we will look at a banking app use-case example.</p>

<p>Note: Trust Insights is an iOS 27 API and is currently marked as beta. You need Xcode 27 or later, and the API may change before its final release.</p>

<h2 id="create-entitlement">Create Entitlement</h2>
<p>First, creating an entitlement is required. You can do so by going to your app target, press “+ Capability” and select “Trust Insights”.</p>

<h2 id="setup">Setup</h2>

<p>To request a trust insight evaluation, we need to provide 2 elements: the request and the context.</p>

<p><strong><em>request</em></strong> - The type of insight we want the framework to evaluate. Currently, the framework supports one type of request, <code class="language-plaintext highlighter-rouge">IsLikelyBeingCoachedInsight</code>. As we can understand from the name, it is for scenarios where the user may be coached by someone to perform certain actions. Potentially, we may see more types of requests in the future.</p>

<p><strong><em>context</em></strong> - The kind of action the user is trying to perform.</p>

<p>Here are all the available contexts we can choose:</p>

<p><code class="language-plaintext highlighter-rouge">.payment</code>
An action that indicates some form of payment or purchase.</p>

<p><code class="language-plaintext highlighter-rouge">.account</code>
An account operation including registration, login, or the modification of account details.</p>

<p><code class="language-plaintext highlighter-rouge">.resourceUse</code>
Usage of some resource, online service such as purchasing API credits, cloud rendering, etc.</p>

<p><code class="language-plaintext highlighter-rouge">.communication</code>
An action that indicates communication operation, such as sending bulk messages or making connections to other people.</p>

<p><code class="language-plaintext highlighter-rouge">.other</code>
Fallback when none of the above categories fit. Use cases: Exporting sensitive data, granting remote access, or any custom sensitive action that doesn’t naturally belong to the other categories.</p>

<p>Apple recommends filing a Feedback report with the details relating to the category of your interest, when you are left to use <code class="language-plaintext highlighter-rouge">.other</code>.</p>

<h2 id="implementation">Implementation</h2>

<p>As a basic use-case scenario for Trust Insight, let’s look at a banking app example. We have a person named Bill. One day, he gets a call, and the caller introduces himself as a bank employee and tells him that money was accidentally transferred to Bill’s account and asks him to return it to another account.</p>

<p><img src="/assets/images/trust-insights-coaching-scam.png" alt="Bill being coached by a scammer while making a bank transfer" style="width: 100%; aspect-ratio: 12 / 5; object-fit: cover; object-position: center;" /></p>

<p>Bill says ok, opens the app, eventually comes to the final screen to confirm the transaction, and presses the confirm button. Before processing it or sending an authentication code, we can create and call Trust Insight request, wait for the result, and decide the next action based on it.</p>

<p>Here is a look at our implementation in a view model.</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="cp">#if canImport(TrustInsights)</span>
<span class="kd">import</span> <span class="kt">TrustInsights</span>
<span class="cp">#endif</span>

<span class="kd">final</span> <span class="kd">class</span> <span class="kt">OurViewModel</span> <span class="p">{</span>

    <span class="c1">// some code</span>

    <span class="cp">#if canImport(TrustInsights)</span>
    <span class="kd">@available</span><span class="p">(</span><span class="n">iOS</span> <span class="mf">27.0</span><span class="p">,</span> <span class="o">*</span><span class="p">)</span>
    <span class="kd">func</span> <span class="nf">evaluateCoachingRiskWithTrustInsights</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">IsLikelyBeingCoachedInsight</span><span class="o">.</span><span class="nf">request</span><span class="p">(</span>
            <span class="nv">schema</span><span class="p">:</span> <span class="o">.</span><span class="n">version1</span><span class="p">,</span>
            <span class="nv">modelVersion</span><span class="p">:</span> <span class="o">.</span><span class="n">current</span>
        <span class="p">)</span>

        <span class="k">let</span> <span class="nv">context</span> <span class="o">=</span> <span class="kt">InsightEvaluator</span><span class="o">.</span><span class="kt">InsightContext</span><span class="p">(</span>
            <span class="nv">operationCategory</span><span class="p">:</span> <span class="o">.</span><span class="n">payment</span><span class="p">,</span>
            <span class="nv">requestedEvaluations</span><span class="p">:</span> <span class="n">request</span>
        <span class="p">)</span>

        <span class="k">let</span> <span class="nv">evaluator</span> <span class="o">=</span> <span class="kt">InsightEvaluator</span><span class="p">()</span>

        <span class="k">do</span> <span class="p">{</span>
            <span class="k">guard</span> <span class="k">try</span> <span class="k">await</span> <span class="n">evaluator</span><span class="o">.</span><span class="nf">requestAuthorization</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">context</span><span class="p">)</span> <span class="o">==</span> <span class="o">.</span><span class="n">authorized</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

            <span class="k">let</span> <span class="nv">assessment</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">evaluator</span><span class="o">.</span><span class="nf">requestEvaluation</span><span class="p">(</span><span class="nv">context</span><span class="p">:</span> <span class="n">context</span><span class="p">)</span>
            <span class="k">let</span> <span class="nv">outcome</span> <span class="o">=</span> <span class="k">try</span> <span class="n">assessment</span><span class="o">.</span><span class="n">insight</span><span class="o">.</span><span class="n">outcome</span><span class="o">.</span><span class="nf">get</span><span class="p">()</span>

            <span class="k">switch</span> <span class="n">outcome</span> <span class="p">{</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">unknown</span><span class="p">:</span>
                <span class="n">assessment</span><span class="o">.</span><span class="nf">reportConsumption</span><span class="p">(</span><span class="o">.</span><span class="n">usedUnchangedFriction</span><span class="p">)</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">medium</span><span class="p">:</span>
                <span class="nf">addExtraVerificationStep</span><span class="p">()</span>
                
                <span class="n">assessment</span><span class="o">.</span><span class="nf">reportConsumption</span><span class="p">(</span><span class="o">.</span><span class="n">usedIncreasedFriction</span><span class="p">)</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">high</span><span class="p">:</span>
                <span class="nf">reportToCallService</span><span class="p">()</span>
                
                <span class="n">assessment</span><span class="o">.</span><span class="nf">reportConsumption</span><span class="p">(</span><span class="o">.</span><span class="n">usedIncreasedFriction</span><span class="p">)</span>
            <span class="kd">@unknown</span> <span class="k">default</span><span class="p">:</span>
                <span class="n">assessment</span><span class="o">.</span><span class="nf">reportConsumption</span><span class="p">(</span><span class="o">.</span><span class="n">notUsedError</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{}</span>
    <span class="p">}</span>
    <span class="cp">#endif</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="a-few-things-to-look-out-for-in-the-code">A few things to look out for in the code:</h3>

<ul>
  <li>
    <p>Don’t we feed it any data? The answer is no, at least not from our app. We only provide the operation context, such as <code class="language-plaintext highlighter-rouge">.payment</code>, and request the evaluation. We don’t send the person’s messages, call audio, transaction details, or any other custom payload to the framework.</p>

    <p>So how does Trust Insights really work under the hood? Apple gives us a high-level explanation in the <a href="https://developer.apple.com/videos/play/wwdc2026/379/">Meet Trust Insights</a> session, but does not reveal the model architecture or exactly how each signal is weighted. The framework uses a machine-learning model and combines on-device processing with Apple’s cloud infrastructure. On the device, it looks at signals such as interaction patterns, timing, context, and basic sensor data. Apple specifically says that it does not inspect content from Photos, Messages, or Mail. Device-sourced data is processed locally and discarded immediately after the evaluation; only a single result value leaves the device. Apple’s service may then combine that result with Apple Account signals and velocity checks—patterns such as unusual activity happening repeatedly or too quickly—to add more context before returning <code class="language-plaintext highlighter-rouge">.unknown</code>, <code class="language-plaintext highlighter-rouge">.medium</code>, or <code class="language-plaintext highlighter-rouge">.high</code> to the app.</p>
  </li>
  <li>For <code class="language-plaintext highlighter-rouge">unknown</code> outcome case, we are not actually doing anything in the code example. But <code class="language-plaintext highlighter-rouge">unknown</code> does not mean there is no risk, so taking some precautionary actions, such as adding extra friction is still recommended.</li>
  <li>What is <code class="language-plaintext highlighter-rouge">assessment.reportConsumption(:)</code>? - calling this is actually mandatory by Apple by after each evalutation request. Rate limits may apply if you decide not to call it with the correct consumption value related to your taken action. Here are 6 types available:
    <ol>
      <li><code class="language-plaintext highlighter-rouge">.usedReducedFriction</code> — insight made operation easier, caused your app to remove or reduce security friction for the user</li>
      <li><code class="language-plaintext highlighter-rouge">.usedUnchangedFriction</code> — insight evaluated but didn’t change experience for user</li>
      <li><code class="language-plaintext highlighter-rouge">.usedIncreasedFriction</code> — insight led to additional checks (as I mentioned earlier, blocking the whole operation based solely on this is not recommended.)</li>
      <li><code class="language-plaintext highlighter-rouge">.notUsedNotNeeded</code> — user cancelled the operation</li>
      <li><code class="language-plaintext highlighter-rouge">.notUsedError</code> — technical failure (e.g. result arrived too late)</li>
      <li><code class="language-plaintext highlighter-rouge">.usedEvaluationOnly</code> — insight used for internal benchmarking only, no UX impact</li>
    </ol>
  </li>
  <li>There is also a thing called Offline label submissions, which is for cases in which the evaluation ultimately results in a confirmed fraud after days, weeks, or months. It’s not mandatory, but again, recommended by Apple to understand the model’s real-world performance and strengthen the ecosystem. Learn more about it by watching the <a href="https://developer.apple.com/videos/play/wwdc2026/379/">Meet Trust Insights</a>.</li>
</ul>

<p>Well, back to Bill. After we run this insight evalution, it told us there is a high-scam risk. So in our banking app example, we can pause the operation, show an alert to Bill to wait for a few minutes due to a possibly suspicious operation, and notify the call-service members to get in contact with Bill to make sure it’s all intentional and confirm the operation.</p>

<p>Note: Sandbox environment is used for Trust Insight during development. Production models/servers are used after App Store distribution.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Trust Insights is not something that can completely stop social engineering scams, and it should not be treated as the final decision-maker. But still it gives us something that the usual security protections don’t: a signal that the person performing a legitimate action may actually be doing it under someone else’s pressure.</p>

<p>As we saw, requesting an evaluation is the easy part. The more important part is deciding when to request it and what to do with the result. Depending on your app, this may mean showing a warning, delaying the operation, asking for additional verification, or sending it for manual review by a human. The response should be proportional to the risk, and a trust insight should always work together with your existing fraud and security logic.</p>

<p>That’s it for now,
Thanks for reading!</p>]]></content><author><name>Alpay Calalli</name></author><category term="swift" /><category term="TrustInsight" /><summary type="html"><![CDATA[“The Trust Insights framework enables your app to request an evaluation, or insight, to help detect and respond to social engineering threats people may face.” as the official TrustInsight documentation says.]]></summary></entry><entry><title type="html">Building an On-Device Video Editor app with Swift/SwiftUI and SpeechAnalyzer — Part 3</title><link href="https://alpayc.com/blog/2026/08/24/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-3" rel="alternate" type="text/html" title="Building an On-Device Video Editor app with Swift/SwiftUI and SpeechAnalyzer — Part 3" /><published>2026-08-24T13:00:00+00:00</published><updated>2026-08-24T13:00:00+00:00</updated><id>https://alpayc.com/blog/2026/08/24/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-3</id><content type="html" xml:base="https://alpayc.com/blog/2026/08/24/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-3"><![CDATA[<p>Here we are with Part 3!</p>

<p>If you are new here, head back to <a href="/blog/2026/08/17/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-1">Part 1</a> and <a href="/blog/2026/08/20/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-2">Part 2</a> to see the progress so far. In this part, we will look at how to split the full transcript into subtitle groups and show them on top of the video player.</p>

<p>We will show subtitles in three ways, and the user can choose the one that suits them:</p>

<ul>
  <li>One word at a time</li>
  <li>A group of words</li>
  <li>A group of words with the currently spoken word highlighted</li>
</ul>

<p>Let’s define these methods as an enum.</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">enum</span> <span class="kt">SubtitleShowMethod</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span><span class="p">,</span> <span class="kt">Identifiable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">oneWord</span>
    <span class="k">case</span> <span class="n">wholeSentence</span>
    <span class="k">case</span> <span class="n">wholeSentenceWithHighlightedWord</span>

    <span class="k">var</span> <span class="nv">id</span><span class="p">:</span> <span class="k">Self</span> <span class="p">{</span> <span class="k">self</span> <span class="p">}</span>

    <span class="k">var</span> <span class="nv">description</span><span class="p">:</span> <span class="kt">LocalizedStringResource</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">oneWord</span><span class="p">:</span>
            <span class="s">"Word"</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">wholeSentence</span><span class="p">:</span>
            <span class="s">"Group"</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">wholeSentenceWithHighlightedWord</span><span class="p">:</span>
            <span class="s">"Highlight"</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now, let’s move on to look at how to make our subtitles from the full transcript</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">SubtitleGrouper</span> <span class="p">{</span>
    <span class="kd">private</span> <span class="k">let</span> <span class="nv">maximumCharacterCount</span> <span class="o">=</span> <span class="mi">40</span>

    <span class="kd">func</span> <span class="nf">apply</span><span class="p">(</span><span class="n">to</span> <span class="nv">words</span><span class="p">:</span> <span class="p">[</span><span class="kt">TranscriptWord</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">SubtitleGroup</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">timedWords</span> <span class="o">=</span> <span class="n">words</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">audioTimeRange</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">}</span>
        <span class="k">guard</span> <span class="o">!</span><span class="n">timedWords</span><span class="o">.</span><span class="n">isEmpty</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">[]</span> <span class="p">}</span>

        <span class="k">var</span> <span class="nv">groups</span><span class="p">:</span> <span class="p">[</span><span class="kt">SubtitleGroup</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">var</span> <span class="nv">currentWords</span><span class="p">:</span> <span class="p">[</span><span class="kt">TranscriptWord</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">var</span> <span class="nv">currentCharacterCount</span> <span class="o">=</span> <span class="mi">0</span>

        <span class="k">for</span> <span class="n">word</span> <span class="k">in</span> <span class="n">timedWords</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">separatorCount</span> <span class="o">=</span> <span class="n">currentWords</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">?</span> <span class="mi">0</span> <span class="p">:</span> <span class="mi">1</span>
            <span class="k">let</span> <span class="nv">proposedCount</span> <span class="o">=</span> <span class="n">currentCharacterCount</span> <span class="o">+</span> <span class="n">separatorCount</span> <span class="o">+</span> <span class="n">word</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</span><span class="o">.</span><span class="n">count</span>

            <span class="k">if</span> <span class="n">currentWords</span><span class="o">.</span><span class="n">isEmpty</span> <span class="o">||</span> <span class="n">proposedCount</span> <span class="o">&lt;</span> <span class="n">maximumCharacterCount</span> <span class="p">{</span>
                <span class="n">currentWords</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">word</span><span class="p">)</span>
                <span class="n">currentCharacterCount</span> <span class="o">=</span> <span class="n">proposedCount</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="n">groups</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="nf">makeGroup</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">currentWords</span><span class="p">,</span> <span class="nv">endTime</span><span class="p">:</span> <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">?</span><span class="o">.</span><span class="n">start</span><span class="p">))</span>
                <span class="n">currentWords</span> <span class="o">=</span> <span class="p">[</span><span class="n">word</span><span class="p">]</span>
                <span class="n">currentCharacterCount</span> <span class="o">=</span> <span class="n">word</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</span><span class="o">.</span><span class="n">count</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="k">let</span> <span class="nv">finalEndTime</span> <span class="o">=</span> <span class="n">currentWords</span><span class="o">.</span><span class="n">last</span><span class="p">?</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">?</span><span class="o">.</span><span class="n">end</span> <span class="p">{</span>
            <span class="n">groups</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="nf">makeGroup</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">currentWords</span><span class="p">,</span> <span class="nv">endTime</span><span class="p">:</span> <span class="n">finalEndTime</span><span class="p">))</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">groups</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">makeGroup</span><span class="p">(</span><span class="n">from</span> <span class="nv">words</span><span class="p">:</span> <span class="p">[</span><span class="kt">TranscriptWord</span><span class="p">],</span> <span class="nv">endTime</span><span class="p">:</span> <span class="kt">CMTime</span><span class="p">?)</span> <span class="o">-&gt;</span> <span class="kt">SubtitleGroup</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">startTime</span> <span class="o">=</span> <span class="n">words</span><span class="o">.</span><span class="n">first</span><span class="p">?</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">?</span><span class="o">.</span><span class="n">start</span> <span class="p">??</span> <span class="o">.</span><span class="n">zero</span>
        <span class="k">let</span> <span class="nv">endTime</span> <span class="o">=</span> <span class="n">endTime</span> <span class="p">??</span> <span class="n">words</span><span class="o">.</span><span class="n">last</span><span class="p">?</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">?</span><span class="o">.</span><span class="n">end</span> <span class="p">??</span> <span class="n">startTime</span>
        <span class="k">let</span> <span class="nv">text</span> <span class="o">=</span> <span class="kt">AttributedString</span><span class="p">(</span>
            <span class="n">words</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">$0</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</span><span class="p">)</span> <span class="p">}</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="p">)</span>
        <span class="p">)</span>

        <span class="k">return</span> <span class="kt">SubtitleGroup</span><span class="p">(</span>
            <span class="nv">text</span><span class="p">:</span> <span class="n">text</span><span class="p">,</span>
            <span class="nv">words</span><span class="p">:</span> <span class="n">words</span><span class="p">,</span>
            <span class="nv">startTime</span><span class="p">:</span> <span class="n">startTime</span><span class="p">,</span>
            <span class="nv">endTime</span><span class="p">:</span> <span class="n">endTime</span>
        <span class="p">)</span>
    <span class="p">}</span>

<span class="p">}</span>
</code></pre></div></div>

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

<span class="c1">/// A short group of timed words displayed together over the video.</span>
<span class="kd">struct</span> <span class="kt">SubtitleGroup</span><span class="p">:</span> <span class="kt">Identifiable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">id</span> <span class="o">=</span> <span class="kt">UUID</span><span class="p">()</span>
    <span class="k">let</span> <span class="nv">text</span><span class="p">:</span> <span class="kt">AttributedString</span>
    <span class="k">let</span> <span class="nv">words</span><span class="p">:</span> <span class="p">[</span><span class="kt">TranscriptWord</span><span class="p">]</span>
    <span class="k">let</span> <span class="nv">startTime</span><span class="p">:</span> <span class="kt">CMTime</span>
    <span class="k">let</span> <span class="nv">endTime</span><span class="p">:</span> <span class="kt">CMTime</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To keep things simple, the only rule I use to separate subtitles is an exclusive character-count limit. With <code class="language-plaintext highlighter-rouge">maximumCharacterCount</code> set to 40, each group stays below 40 characters. You can extend this with more detailed grouping rules if needed.
Storing both <code class="language-plaintext highlighter-rouge">text</code> and <code class="language-plaintext highlighter-rouge">words</code> properties helps us to have more control over each word like we did the same for the transcript model.</p>

<p>Well. since we are going to show each word as a separate UI element, again, we need to define a custom <code class="language-plaintext highlighter-rouge">Layout</code> for the subtitles.</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="c1">/// Wraps subtitle words into centered rows.</span>
<span class="kd">struct</span> <span class="kt">SubtitleLayout</span><span class="p">:</span> <span class="kt">Layout</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">alignment</span><span class="p">:</span> <span class="kt">Alignment</span> <span class="o">=</span> <span class="o">.</span><span class="n">center</span>
    <span class="k">var</span> <span class="nv">spacing</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">4</span>

    <span class="kd">func</span> <span class="nf">sizeThatFits</span><span class="p">(</span>
        <span class="nv">proposal</span><span class="p">:</span> <span class="kt">ProposedViewSize</span><span class="p">,</span>
        <span class="nv">subviews</span><span class="p">:</span> <span class="kt">Subviews</span><span class="p">,</span>
        <span class="nv">cache</span><span class="p">:</span> <span class="nf">inout</span> <span class="p">()</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">CGSize</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">maximumWidth</span> <span class="o">=</span> <span class="n">proposal</span><span class="o">.</span><span class="n">width</span> <span class="p">??</span> <span class="o">.</span><span class="n">infinity</span>
        <span class="k">var</span> <span class="nv">x</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">var</span> <span class="nv">y</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">var</span> <span class="nv">rowHeight</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>

        <span class="k">for</span> <span class="n">subview</span> <span class="k">in</span> <span class="n">subviews</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">size</span> <span class="o">=</span> <span class="n">subview</span><span class="o">.</span><span class="nf">sizeThatFits</span><span class="p">(</span><span class="o">.</span><span class="n">unspecified</span><span class="p">)</span>

            <span class="k">if</span> <span class="n">x</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">x</span> <span class="o">+</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">&gt;</span> <span class="n">maximumWidth</span> <span class="p">{</span>
                <span class="n">x</span> <span class="o">=</span> <span class="mi">0</span>
                <span class="n">y</span> <span class="o">+=</span> <span class="n">rowHeight</span>
                <span class="n">rowHeight</span> <span class="o">=</span> <span class="mi">0</span>
            <span class="p">}</span>

            <span class="n">x</span> <span class="o">+=</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">+</span> <span class="n">spacing</span>
            <span class="n">rowHeight</span> <span class="o">=</span> <span class="nf">max</span><span class="p">(</span><span class="n">rowHeight</span><span class="p">,</span> <span class="n">size</span><span class="o">.</span><span class="n">height</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="kt">CGSize</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="n">maximumWidth</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="n">y</span> <span class="o">+</span> <span class="n">rowHeight</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">placeSubviews</span><span class="p">(</span>
        <span class="k">in</span> <span class="nv">bounds</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">,</span>
        <span class="nv">proposal</span><span class="p">:</span> <span class="kt">ProposedViewSize</span><span class="p">,</span>
        <span class="nv">subviews</span><span class="p">:</span> <span class="kt">Subviews</span><span class="p">,</span>
        <span class="nv">cache</span><span class="p">:</span> <span class="nf">inout</span> <span class="p">()</span>
    <span class="p">)</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">rows</span> <span class="o">=</span> <span class="nf">rows</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">subviews</span><span class="p">,</span> <span class="nv">maximumWidth</span><span class="p">:</span> <span class="n">bounds</span><span class="o">.</span><span class="n">width</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">y</span> <span class="o">=</span> <span class="n">bounds</span><span class="o">.</span><span class="n">minY</span>

        <span class="k">for</span> <span class="n">row</span> <span class="k">in</span> <span class="n">rows</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">rowWidth</span> <span class="o">=</span> <span class="n">row</span><span class="o">.</span><span class="nf">reduce</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$0</span> <span class="o">+</span> <span class="nv">$1</span><span class="o">.</span><span class="nf">sizeThatFits</span><span class="p">(</span><span class="o">.</span><span class="n">unspecified</span><span class="p">)</span><span class="o">.</span><span class="n">width</span>
            <span class="p">}</span> <span class="o">+</span> <span class="kt">CGFloat</span><span class="p">(</span><span class="nf">max</span><span class="p">(</span><span class="n">row</span><span class="o">.</span><span class="n">count</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span> <span class="o">*</span> <span class="n">spacing</span>

            <span class="k">var</span> <span class="nv">x</span> <span class="o">=</span> <span class="nf">startingX</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">rowWidth</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="n">bounds</span><span class="p">)</span>
            <span class="k">var</span> <span class="nv">rowHeight</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>

            <span class="k">for</span> <span class="n">subview</span> <span class="k">in</span> <span class="n">row</span> <span class="p">{</span>
                <span class="k">let</span> <span class="nv">size</span> <span class="o">=</span> <span class="n">subview</span><span class="o">.</span><span class="nf">sizeThatFits</span><span class="p">(</span><span class="o">.</span><span class="n">unspecified</span><span class="p">)</span>
                <span class="n">subview</span><span class="o">.</span><span class="nf">place</span><span class="p">(</span>
                    <span class="nv">at</span><span class="p">:</span> <span class="kt">CGPoint</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="n">x</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="n">y</span><span class="p">),</span>
                    <span class="nv">proposal</span><span class="p">:</span> <span class="kt">ProposedViewSize</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>
                <span class="p">)</span>
                <span class="n">x</span> <span class="o">+=</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">+</span> <span class="n">spacing</span>
                <span class="n">rowHeight</span> <span class="o">=</span> <span class="nf">max</span><span class="p">(</span><span class="n">rowHeight</span><span class="p">,</span> <span class="n">size</span><span class="o">.</span><span class="n">height</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="n">y</span> <span class="o">+=</span> <span class="n">rowHeight</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">rows</span><span class="p">(</span><span class="k">for</span> <span class="nv">subviews</span><span class="p">:</span> <span class="kt">Subviews</span><span class="p">,</span> <span class="nv">maximumWidth</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="p">[[</span><span class="kt">LayoutSubview</span><span class="p">]]</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">rows</span><span class="p">:</span> <span class="p">[[</span><span class="kt">LayoutSubview</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">var</span> <span class="nv">currentRow</span><span class="p">:</span> <span class="p">[</span><span class="kt">LayoutSubview</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">var</span> <span class="nv">currentWidth</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>

        <span class="k">for</span> <span class="n">subview</span> <span class="k">in</span> <span class="n">subviews</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">size</span> <span class="o">=</span> <span class="n">subview</span><span class="o">.</span><span class="nf">sizeThatFits</span><span class="p">(</span><span class="o">.</span><span class="n">unspecified</span><span class="p">)</span>

            <span class="k">if</span> <span class="o">!</span><span class="n">currentRow</span><span class="o">.</span><span class="n">isEmpty</span><span class="p">,</span> <span class="n">currentWidth</span> <span class="o">+</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">&gt;</span> <span class="n">maximumWidth</span> <span class="p">{</span>
                <span class="n">rows</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">currentRow</span><span class="p">)</span>
                <span class="n">currentRow</span> <span class="o">=</span> <span class="p">[]</span>
                <span class="n">currentWidth</span> <span class="o">=</span> <span class="mi">0</span>
            <span class="p">}</span>

            <span class="n">currentRow</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">subview</span><span class="p">)</span>
            <span class="n">currentWidth</span> <span class="o">+=</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">+</span> <span class="n">spacing</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="o">!</span><span class="n">currentRow</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">{</span>
            <span class="n">rows</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">currentRow</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">rows</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">startingX</span><span class="p">(</span><span class="k">for</span> <span class="nv">rowWidth</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span> <span class="k">in</span> <span class="nv">bounds</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">CGFloat</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">alignment</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">leading</span><span class="p">:</span>
            <span class="n">bounds</span><span class="o">.</span><span class="n">minX</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">trailing</span><span class="p">:</span>
            <span class="n">bounds</span><span class="o">.</span><span class="n">maxX</span> <span class="o">-</span> <span class="n">rowWidth</span>
        <span class="k">default</span><span class="p">:</span>
            <span class="n">bounds</span><span class="o">.</span><span class="n">minX</span> <span class="o">+</span> <span class="p">(</span><span class="n">bounds</span><span class="o">.</span><span class="n">width</span> <span class="o">-</span> <span class="n">rowWidth</span><span class="p">)</span> <span class="o">/</span> <span class="mi">2</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Well, I didn’t write all these mathematical calculations by hand, AI did almost the whole job, but basically what’s happening here is that the layout measures each word, moves it to a new row when it no longer fits within the available width, and then centers each row.</p>

<h2 id="subtitleoverlayview">SubtitleOverlayView</h2>

<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">SubtitleOverlayView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">group</span><span class="p">:</span> <span class="kt">SubtitleGroup</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">currentWord</span><span class="p">:</span> <span class="kt">TranscriptWord</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">showMethod</span><span class="p">:</span> <span class="kt">SubtitleShowMethod</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">if</span> <span class="k">let</span> <span class="nv">group</span> <span class="p">{</span>
                <span class="k">switch</span> <span class="n">showMethod</span> <span class="p">{</span>
                <span class="k">case</span> <span class="o">.</span><span class="nv">oneWord</span><span class="p">:</span>
                    <span class="k">if</span> <span class="k">let</span> <span class="nv">currentWord</span> <span class="p">{</span>
                        <span class="kt">Text</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="n">currentWord</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</span><span class="p">))</span>
                    <span class="p">}</span>
                <span class="k">case</span> <span class="o">.</span><span class="nv">wholeSentence</span><span class="p">:</span>
                    <span class="kt">Text</span><span class="p">(</span><span class="n">group</span><span class="o">.</span><span class="n">text</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="k">case</span> <span class="o">.</span><span class="nv">wholeSentenceWithHighlightedWord</span><span class="p">:</span>
                    <span class="kt">SubtitleLayout</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">ForEach</span><span class="p">(</span><span class="n">group</span><span class="o">.</span><span class="n">words</span><span class="p">)</span> <span class="p">{</span> <span class="n">word</span> <span class="k">in</span>
                            <span class="kt">Text</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="n">word</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</span><span class="p">))</span>
                                <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span>
                                    <span class="n">word</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="n">currentWord</span><span class="p">?</span><span class="o">.</span><span class="n">id</span> <span class="p">?</span> <span class="o">.</span><span class="nv">yellow</span> <span class="p">:</span> <span class="o">.</span><span class="n">white</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.95</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="p">}</span>
        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="mi">24</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">bottom</span><span class="p">,</span> <span class="mi">24</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="nv">maxHeight</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">,</span> <span class="nv">alignment</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">font</span><span class="p">(</span><span class="o">.</span><span class="n">headline</span><span class="o">.</span><span class="nf">weight</span><span class="p">(</span><span class="o">.</span><span class="n">semibold</span><span class="p">))</span>
        <span class="o">.</span><span class="nf">fontDesign</span><span class="p">(</span><span class="o">.</span><span class="n">rounded</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.85</span><span class="p">),</span> <span class="nv">radius</span><span class="p">:</span> <span class="mi">6</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">allowsHitTesting</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">accessibilityHidden</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is our view for displaying the subtitle depending on the selected style of user. As you can see, <code class="language-plaintext highlighter-rouge">SubtitleLayout</code> is used here for putting all words inside <code class="language-plaintext highlighter-rouge">ForEach</code> and highlighting the specific <code class="language-plaintext highlighter-rouge">Text</code> view by comparing ids’(<code class="language-plaintext highlighter-rouge">word.id == currentWord?.id</code>). For all the other cases, putting single <code class="language-plaintext highlighter-rouge">Text</code> view is enough</p>

<h2 id="viewmodel-setup">ViewModel setup</h2>
<p>Now we have implemented all the pieces, it’s time to put them in places.
Here are the new properties we want to introduce in our ViewModel</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">final</span> <span class="kd">class</span> <span class="kt">TranscriptDemoViewModel</span> <span class="p">{</span>
    <span class="cm">/* ... */</span>

    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">subtitleGroups</span><span class="p">:</span> <span class="p">[</span><span class="kt">SubtitleGroup</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span> <span class="c1">// +</span>
    <span class="c1">/// User's selected style to show subtitles. // +</span>
    <span class="k">var</span> <span class="nv">subtitleShowMethod</span><span class="p">:</span> <span class="kt">SubtitleShowMethod</span> <span class="o">=</span> <span class="o">.</span><span class="n">wholeSentenceWithHighlightedWord</span> <span class="c1">// +</span>

    <span class="k">var</span> <span class="nv">currentSubtitleGroup</span><span class="p">:</span> <span class="kt">SubtitleGroup</span><span class="p">?</span> <span class="p">{</span> <span class="c1">// +</span>
        <span class="n">subtitleGroups</span><span class="o">.</span><span class="n">first</span> <span class="p">{</span> <span class="n">group</span> <span class="k">in</span> <span class="c1">// +</span>
            <span class="n">group</span><span class="o">.</span><span class="n">startTime</span><span class="o">.</span><span class="n">seconds</span> <span class="o">&lt;=</span> <span class="n">playbackTime</span> <span class="o">&amp;&amp;</span> <span class="n">playbackTime</span> <span class="o">&lt;</span> <span class="n">group</span><span class="o">.</span><span class="n">endTime</span><span class="o">.</span><span class="n">seconds</span> <span class="c1">// +</span>
        <span class="p">}</span> <span class="c1">// +</span>
    <span class="p">}</span> <span class="c1">// +</span>

    <span class="k">var</span> <span class="nv">currentSubtitleWord</span><span class="p">:</span> <span class="kt">TranscriptWord</span><span class="p">?</span> <span class="p">{</span> <span class="c1">// +</span>
        <span class="n">currentSubtitleGroup</span><span class="p">?</span><span class="o">.</span><span class="n">words</span><span class="o">.</span><span class="n">first</span> <span class="p">{</span> <span class="n">word</span> <span class="k">in</span> <span class="c1">// +</span>
            <span class="k">guard</span> <span class="k">let</span> <span class="nv">timeRange</span> <span class="o">=</span> <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</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="c1">// +</span>
            <span class="k">return</span> <span class="n">timeRange</span><span class="o">.</span><span class="n">start</span><span class="o">.</span><span class="n">seconds</span> <span class="o">&lt;=</span> <span class="n">playbackTime</span> <span class="o">&amp;&amp;</span> <span class="n">playbackTime</span> <span class="o">&lt;</span> <span class="n">timeRange</span><span class="o">.</span><span class="n">end</span><span class="o">.</span><span class="n">seconds</span> <span class="c1">// +</span>
        <span class="p">}</span> <span class="c1">// +</span>
    <span class="p">}</span> <span class="c1">// +</span>

    <span class="cm">/* ... */</span>
<span class="p">}</span>
</code></pre></div></div>
<p>And update <code class="language-plaintext highlighter-rouge">processVideo(at:) method</code></p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">processVideo</span><span class="p">(</span><span class="n">at</span> <span class="nv">videoURL</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="p">{</span>
    <span class="c1">// Playback can be prepared immediately; transcription does not need to</span>
    <span class="c1">// finish before the user can inspect the selected video.</span>
    <span class="nf">preparePlayer</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">videoURL</span><span class="p">)</span>
    <span class="n">transcript</span> <span class="o">=</span> <span class="kc">nil</span>
    <span class="n">transcriptWords</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">subtitleGroups</span> <span class="o">=</span> <span class="p">[]</span> <span class="c1">// +</span>
    <span class="n">playbackTime</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">extractingAudio</span>

    <span class="k">let</span> <span class="nv">audioURL</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">audioExtractor</span><span class="o">.</span><span class="nf">extractAudio</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">videoURL</span><span class="p">)</span>
    <span class="k">defer</span> <span class="p">{</span>
        <span class="c1">// The extracted track is an intermediate input, not user data.</span>
        <span class="k">try</span><span class="p">?</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="nf">removeItem</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">audioURL</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">transcribing</span>
    <span class="k">let</span> <span class="nv">transcript</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">transcriber</span><span class="o">.</span><span class="nf">transcribe</span><span class="p">(</span><span class="nv">audioAt</span><span class="p">:</span> <span class="n">audioURL</span><span class="p">)</span>
    <span class="k">self</span><span class="o">.</span><span class="n">transcript</span> <span class="o">=</span> <span class="n">transcript</span>
    <span class="n">transcriptWords</span> <span class="o">=</span> <span class="n">transcript</span><span class="o">.</span><span class="nf">transcriptWords</span><span class="p">()</span>
    <span class="n">subtitleGroups</span> <span class="o">=</span> <span class="n">subtitleGrouper</span><span class="o">.</span><span class="nf">apply</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">transcriptWords</span><span class="p">)</span> <span class="c1">// +</span>
    <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">ready</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="view-setup">View setup</h2>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">24</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="k">let</span> <span class="nv">player</span> <span class="o">=</span> <span class="n">model</span><span class="o">.</span><span class="n">player</span> <span class="p">{</span>
        <span class="kt">VideoPlayer</span><span class="p">(</span><span class="nv">player</span><span class="p">:</span> <span class="n">player</span><span class="p">)</span> <span class="c1">// -</span>
        <span class="kt">VideoPlayer</span><span class="p">(</span><span class="nv">player</span><span class="p">:</span> <span class="n">player</span><span class="p">)</span> <span class="p">{</span> <span class="c1">// +</span>
            <span class="kt">SubtitleOverlayView</span><span class="p">(</span> <span class="c1">// +</span>
                <span class="nv">group</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">currentSubtitleGroup</span><span class="p">,</span> <span class="c1">// +</span>
                <span class="nv">currentWord</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">currentSubtitleWord</span><span class="p">,</span> <span class="c1">// +</span>
                <span class="nv">showMethod</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">subtitleShowMethod</span> <span class="c1">// +</span>
            <span class="p">)</span> <span class="c1">// +</span>
        <span class="p">}</span> <span class="c1">// +</span>
        <span class="o">.</span><span class="nf">aspectRatio</span><span class="p">(</span><span class="mi">16</span> <span class="o">/</span> <span class="mi">9</span><span class="p">,</span> <span class="nv">contentMode</span><span class="p">:</span> <span class="o">.</span><span class="n">fit</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="o">.</span><span class="nf">rect</span><span class="p">(</span><span class="nv">cornerRadius</span><span class="p">:</span> <span class="mi">12</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="kt">SubtitleShowMethodPicker</span><span class="p">(</span><span class="nv">selection</span><span class="p">:</span> <span class="err">$</span><span class="n">model</span><span class="o">.</span><span class="n">subtitleShowMethod</span><span class="p">)</span> <span class="c1">// +</span>

    <span class="kt">TranscriptContainerView</span><span class="p">(</span>
        <span class="cm">/* ... */</span>
    <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>
<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">SubtitleShowMethodPicker</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@Binding</span> <span class="k">var</span> <span class="nv">selection</span><span class="p">:</span> <span class="kt">SubtitleShowMethod</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">Picker</span><span class="p">(</span><span class="s">"Subtitle Display"</span><span class="p">,</span> <span class="nv">selection</span><span class="p">:</span> <span class="err">$</span><span class="n">selection</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">ForEach</span><span class="p">(</span><span class="kt">SubtitleShowMethod</span><span class="o">.</span><span class="n">allCases</span><span class="p">)</span> <span class="p">{</span> <span class="n">method</span> <span class="k">in</span>
                <span class="kt">Text</span><span class="p">(</span><span class="n">method</span><span class="o">.</span><span class="n">description</span><span class="p">)</span>
                    <span class="o">.</span><span class="nf">tag</span><span class="p">(</span><span class="n">method</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">pickerStyle</span><span class="p">(</span><span class="o">.</span><span class="n">segmented</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="result">Result</h2>
<section class="post-video-intro post-video-intro--compact">
  <div class="post-video-intro__text">
    <p>We now have a nicely working subtitles appearing over the video in 3 different versions with seamless transitions while switching between them.</p>

    <p><a href="/assets/downloads/video-editor-part-3.zip" download="">Download</a> project after changes here in part 3.</p>

    <p>Thanks for reading, see you in the part 4!</p>
  </div>

  <figure class="post-video-intro__media">
    <video controls="" playsinline="" preload="metadata">
      <source src="/assets/images/video-editor-part-3-result.MP4" type="video/mp4" />
      Your browser does not support embedded videos.
    </video>
    <figcaption>
      Final Result.
    </figcaption>
  </figure>
</section>]]></content><author><name>Alpay Calalli</name></author><category term="swift" /><category term="swiftui" /><category term="speech" /><category term="speech-to-text" /><category term="avfoundation" /><summary type="html"><![CDATA[Here we are with Part 3!]]></summary></entry><entry><title type="html">Building an On-Device Video Editor app with Swift/SwiftUI and SpeechAnalyzer — Part 2</title><link href="https://alpayc.com/blog/2026/08/20/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-2" rel="alternate" type="text/html" title="Building an On-Device Video Editor app with Swift/SwiftUI and SpeechAnalyzer — Part 2" /><published>2026-08-20T13:00:00+00:00</published><updated>2026-08-20T13:00:00+00:00</updated><id>https://alpayc.com/blog/2026/08/20/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-2</id><content type="html" xml:base="https://alpayc.com/blog/2026/08/20/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-2"><![CDATA[<p>In <a href="/blog/2026/08/17/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-1">Part 1</a>, we imported a video, transcribed its audio fully on-device, and used the timing attributes returned by <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> to highlight the currently spoken word.</p>

<p>This time, we are going to make the transcript interactive. The user will be able to tap any word and jump directly to the moment where it is spoken in the video.</p>

<p>In this part, we will look at how to:</p>

<ul>
  <li>Split an <code class="language-plaintext highlighter-rouge">AttributedString</code> into individual words without losing Speech’s attributes that we are gonna need.</li>
  <li>Display every word as a separate SwiftUI button.</li>
  <li>Jump to the exact starting time of the selected word.</li>
</ul>

<p>If you want to get the current version of the project that was built in part 1, <a href="/assets/downloads/video-editor-part-1.zip" download="">here</a> you can download.</p>

<h2 id="why-we-cannot-keep-using-one-full-text-view-for-the-whole-transcription">Why we cannot keep using one full Text view for the whole transcription</h2>

<p>In Part 1, the entire transcript was displayed with one <code class="language-plaintext highlighter-rouge">Text</code> view.</p>

<p>That worked nicely for highlighting because we could create a copy of the attributed transcript, find the range for the current word, and change the styling of that range.</p>

<p>However, now we want each word to have its own tap action. A single <code class="language-plaintext highlighter-rouge">Text</code> view does not give us a separate button for every attributed range, so we need to split the transcript into individual values and render them separately.</p>

<p>There is one important detail here: we should not convert the transcript to a regular <code class="language-plaintext highlighter-rouge">String</code> before splitting it.</p>

<p>The <code class="language-plaintext highlighter-rouge">audioTimeRange</code> that tells us when a word was spoken is stored as an attribute on the <code class="language-plaintext highlighter-rouge">AttributedString</code>. If we first convert it to a <code class="language-plaintext highlighter-rouge">String</code>, split it, and create new attributed strings afterward, that timing information will be gone.</p>

<p>Instead, we split the attributed string through its <code class="language-plaintext highlighter-rouge">characters</code> view and keep each result as an <code class="language-plaintext highlighter-rouge">AttributedSubstring</code>.</p>

<h2 id="creating-a-model-for-each-word">Creating a model for each word</h2>

<p>We begin with a small model that stores one attributed word.</p>

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

<span class="kd">struct</span> <span class="kt">TranscriptWord</span><span class="p">:</span> <span class="kt">Identifiable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">id</span> <span class="o">=</span> <span class="kt">UUID</span><span class="p">()</span>
    <span class="k">let</span> <span class="nv">word</span><span class="p">:</span> <span class="kt">AttributedSubstring</span>

    <span class="k">var</span> <span class="nv">audioTimeRange</span><span class="p">:</span> <span class="kt">CMTimeRange</span><span class="p">?</span> <span class="p">{</span>
        <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">word</code> property is an <code class="language-plaintext highlighter-rouge">AttributedSubstring</code>, not a <code class="language-plaintext highlighter-rouge">String</code>. Because it is still a slice of the original attributed value, it keeps the attributes attached by <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code>.</p>

<p>We also expose its timing as a computed property:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">audioTimeRange</span><span class="p">:</span> <span class="kt">CMTimeRange</span><span class="p">?</span> <span class="p">{</span>
    <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The range is optional because an attributed string can contain parts without an audio timing attribute. Later, words without a valid range will simply have their buttons disabled.(hopefully we won’t face a situation like this in our example.)</p>
<h2 id="splitting-the-attributed-transcript">Splitting the attributed transcript</h2>

<p>Next, we add an extension that turns the complete transcript into an array of <code class="language-plaintext highlighter-rouge">TranscriptWord</code> values.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">extension</span> <span class="kt">AttributedString</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">transcriptWords</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">TranscriptWord</span><span class="p">]</span> <span class="p">{</span>
        <span class="n">characters</span>
            <span class="o">.</span><span class="n">split</span> <span class="p">{</span> <span class="n">character</span> <span class="k">in</span>
                <span class="n">character</span><span class="o">.</span><span class="n">isWhitespace</span> <span class="o">||</span> <span class="n">character</span><span class="o">.</span><span class="n">isNewline</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="n">characterRange</span> <span class="k">in</span>
                <span class="kt">TranscriptWord</span><span class="p">(</span>
                    <span class="nv">word</span><span class="p">:</span> <span class="k">self</span><span class="p">[</span>
                        <span class="n">characterRange</span><span class="o">.</span><span class="n">startIndex</span><span class="o">..&lt;</span><span class="n">characterRange</span><span class="o">.</span><span class="n">endIndex</span>
                    <span class="p">]</span>
                <span class="p">)</span>
            <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>We split the <code class="language-plaintext highlighter-rouge">characters</code> collection wherever we find whitespace or a new line. Each result gives us indices that still belong to the original attributed string, so we can use those indices to create an <code class="language-plaintext highlighter-rouge">AttributedSubstring</code>.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">self</span><span class="p">[</span><span class="n">characterRange</span><span class="o">.</span><span class="n">startIndex</span><span class="o">..&lt;</span><span class="n">characterRange</span><span class="o">.</span><span class="n">endIndex</span><span class="p">]</span>
</code></pre></div></div>

<p>That line is the important part. We are taking a slice of the existing value rather than rebuilding the word from plain characters. In this way, we keep the Speech attributes, including the word’s <code class="language-plaintext highlighter-rouge">audioTimeRange</code>.</p>

<p>Once transcription finishes, we store both versions in the view model:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">transcript</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">transcriber</span><span class="o">.</span><span class="nf">transcribe</span><span class="p">(</span><span class="nv">audioAt</span><span class="p">:</span> <span class="n">audioURL</span><span class="p">)</span>
<span class="k">self</span><span class="o">.</span><span class="n">transcript</span> <span class="o">=</span> <span class="n">transcript</span>
<span class="n">transcriptWords</span> <span class="o">=</span> <span class="n">transcript</span><span class="o">.</span><span class="nf">transcriptWords</span><span class="p">()</span>
</code></pre></div></div>

<p>We still keep the original transcript because it’s gonna be useful in the future. The new array exists for the interactive UI, where every word needs its own identity and action.</p>

<h2 id="displaying-words-as-buttons">Displaying words as buttons</h2>

<p>We can now replace the single transcript <code class="language-plaintext highlighter-rouge">Text</code> with a <code class="language-plaintext highlighter-rouge">ForEach</code> that creates one button for every word.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">DynamicTranscriptTextView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">words</span><span class="p">:</span> <span class="p">[</span><span class="kt">TranscriptWord</span><span class="p">]</span>
    <span class="k">let</span> <span class="nv">playbackTime</span><span class="p">:</span> <span class="kt">TimeInterval</span>
    <span class="k">let</span> <span class="nv">onSelectWord</span><span class="p">:</span> <span class="p">(</span><span class="kt">CMTime</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Void</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">TranscriptLayout</span> <span class="p">{</span>
            <span class="kt">ForEach</span><span class="p">(</span><span class="n">words</span><span class="p">)</span> <span class="p">{</span> <span class="n">word</span> <span class="k">in</span>
                <span class="k">let</span> <span class="nv">isHighlighted</span> <span class="o">=</span> <span class="nf">isHighlighted</span><span class="p">(</span><span class="n">word</span><span class="p">)</span>

                <span class="kt">Button</span> <span class="p">{</span>
                    <span class="k">guard</span> <span class="k">let</span> <span class="nv">startTime</span> <span class="o">=</span> <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">?</span><span class="o">.</span><span class="n">start</span> <span class="k">else</span> <span class="p">{</span>
                        <span class="k">return</span>
                    <span class="p">}</span>

                    <span class="nf">onSelectWord</span><span class="p">(</span><span class="n">startTime</span><span class="p">)</span>
                <span class="p">}</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
                    <span class="kt">Text</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="n">word</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</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">body</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">lineLimit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="n">isHighlighted</span> <span class="p">?</span> <span class="o">.</span><span class="nv">primary</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">underline</span><span class="p">(</span><span class="n">isHighlighted</span><span class="p">)</span>
                <span class="p">}</span>
                <span class="o">.</span><span class="nf">buttonStyle</span><span class="p">(</span><span class="kt">TranscriptWordButtonStyle</span><span class="p">())</span>
                <span class="o">.</span><span class="nf">disabled</span><span class="p">(</span><span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</span> <span class="o">==</span> <span class="kc">nil</span><span class="p">)</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="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="p">}</span>
</code></pre></div></div>

<p>We use <code class="language-plaintext highlighter-rouge">.lineLimit(1)</code> because a single word should never wrap internally. The custom layout will decide when the complete word needs to move to the next line.</p>

<p>The current word is still highlighted by comparing its time range with the player’s time:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">isHighlighted</span><span class="p">(</span><span class="n">_</span> <span class="nv">word</span><span class="p">:</span> <span class="kt">TranscriptWord</span><span class="p">)</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">timeRange</span> <span class="o">=</span> <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</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">let</span> <span class="nv">start</span> <span class="o">=</span> <span class="n">timeRange</span><span class="o">.</span><span class="n">start</span><span class="o">.</span><span class="n">seconds</span>
    <span class="k">let</span> <span class="nv">end</span> <span class="o">=</span> <span class="n">timeRange</span><span class="o">.</span><span class="n">end</span><span class="o">.</span><span class="n">seconds</span>

    <span class="k">return</span> <span class="n">start</span> <span class="o">&lt;=</span> <span class="n">playbackTime</span> <span class="o">&amp;&amp;</span> <span class="n">playbackTime</span> <span class="o">&lt;</span> <span class="n">end</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice that the end comparison uses <code class="language-plaintext highlighter-rouge">&lt;</code> rather than <code class="language-plaintext highlighter-rouge">&lt;=</code>. Two consecutive ranges may meet at the same boundary. Treating the end as exclusive prevents both words from being considered active at that exact time.</p>

<p>I also added a small pressed state and selection feedback through a custom <code class="language-plaintext highlighter-rouge">ButtonStyle</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">TranscriptWordButtonStyle</span><span class="p">:</span> <span class="kt">ButtonStyle</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">makeBody</span><span class="p">(</span><span class="nv">configuration</span><span class="p">:</span> <span class="kt">Configuration</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">configuration</span><span class="o">.</span><span class="n">label</span>
            <span class="o">.</span><span class="n">background</span> <span class="p">{</span>
                <span class="k">if</span> <span class="n">configuration</span><span class="o">.</span><span class="n">isPressed</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">8</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">inset</span><span class="p">(</span><span class="nv">by</span><span class="p">:</span> <span class="o">-</span><span class="mi">4</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">thinMaterial</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">sensoryFeedback</span><span class="p">(</span>
                <span class="o">.</span><span class="n">selection</span><span class="p">,</span>
                <span class="nv">trigger</span><span class="p">:</span> <span class="n">configuration</span><span class="o">.</span><span class="n">isPressed</span>
            <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="making-separate-buttons-flow-like-text">Making separate buttons flow like text</h2>
<p>If we put the buttons in an <code class="language-plaintext highlighter-rouge">HStack</code>, they will stay on one line and eventually go outside the available width. A <code class="language-plaintext highlighter-rouge">LazyVGrid</code> can wrap them, but its column-based structure does not look like a natural paragraph because every item is placed into a fixed column.</p>

<p>What we need is a flow layout: place views from leading to trailing, then move the next view to a new line whenever it no longer fits.</p>

<p>SwiftUI’s <code class="language-plaintext highlighter-rouge">Layout</code> protocol gives us control over exactly that.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">TranscriptLayout</span><span class="p">:</span> <span class="kt">Layout</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">spacing</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">4</span>

    <span class="kd">func</span> <span class="nf">sizeThatFits</span><span class="p">(</span>
        <span class="nv">proposal</span><span class="p">:</span> <span class="kt">ProposedViewSize</span><span class="p">,</span>
        <span class="nv">subviews</span><span class="p">:</span> <span class="kt">Subviews</span><span class="p">,</span>
        <span class="nv">cache</span><span class="p">:</span> <span class="nf">inout</span> <span class="p">()</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">CGSize</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">maxWidth</span> <span class="o">=</span> <span class="n">proposal</span><span class="o">.</span><span class="n">width</span> <span class="p">??</span> <span class="o">.</span><span class="n">infinity</span>
        <span class="k">var</span> <span class="nv">x</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">var</span> <span class="nv">y</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">var</span> <span class="nv">lineHeight</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>

        <span class="k">for</span> <span class="n">subview</span> <span class="k">in</span> <span class="n">subviews</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">size</span> <span class="o">=</span> <span class="n">subview</span><span class="o">.</span><span class="nf">sizeThatFits</span><span class="p">(</span><span class="o">.</span><span class="n">unspecified</span><span class="p">)</span>

            <span class="k">if</span> <span class="n">x</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">x</span> <span class="o">+</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">&gt;</span> <span class="n">maxWidth</span> <span class="p">{</span>
                <span class="n">x</span> <span class="o">=</span> <span class="mi">0</span>
                <span class="n">y</span> <span class="o">+=</span> <span class="n">lineHeight</span> <span class="o">+</span> <span class="n">spacing</span>
                <span class="n">lineHeight</span> <span class="o">=</span> <span class="mi">0</span>
            <span class="p">}</span>

            <span class="n">x</span> <span class="o">+=</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">+</span> <span class="n">spacing</span>
            <span class="n">lineHeight</span> <span class="o">=</span> <span class="nf">max</span><span class="p">(</span><span class="n">lineHeight</span><span class="p">,</span> <span class="n">size</span><span class="o">.</span><span class="n">height</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="kt">CGSize</span><span class="p">(</span>
            <span class="nv">width</span><span class="p">:</span> <span class="n">maxWidth</span><span class="p">,</span>
            <span class="nv">height</span><span class="p">:</span> <span class="n">y</span> <span class="o">+</span> <span class="n">lineHeight</span>
        <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sizeThatFits</code> answers the first question SwiftUI asks our layout: how much space do you need for these subviews?</p>

<p>We measure each word with an unspecified proposal so it can report its natural size. We then keep track of the current horizontal position and the tallest item on the current line.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">x</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">x</span> <span class="o">+</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">&gt;</span> <span class="n">maxWidth</span> <span class="p">{</span>
    <span class="n">x</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="n">y</span> <span class="o">+=</span> <span class="n">lineHeight</span> <span class="o">+</span> <span class="n">spacing</span>
    <span class="n">lineHeight</span> <span class="o">=</span> <span class="mi">0</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When the next word would exceed the proposed width, we reset the horizontal position and increase the vertical position by the previous line’s height.</p>

<p>The <code class="language-plaintext highlighter-rouge">x &gt; 0</code> check prevents us from wrapping before the first word. It also means that a word wider than the complete proposed width stays on its current line instead of causing an empty first line.</p>

<p>Measuring the required size is only half of a custom layout. We also need to place the subviews.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">placeSubviews</span><span class="p">(</span>
    <span class="k">in</span> <span class="nv">bounds</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">,</span>
    <span class="nv">proposal</span><span class="p">:</span> <span class="kt">ProposedViewSize</span><span class="p">,</span>
    <span class="nv">subviews</span><span class="p">:</span> <span class="kt">Subviews</span><span class="p">,</span>
    <span class="nv">cache</span><span class="p">:</span> <span class="nf">inout</span> <span class="p">()</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">x</span> <span class="o">=</span> <span class="n">bounds</span><span class="o">.</span><span class="n">minX</span>
    <span class="k">var</span> <span class="nv">y</span> <span class="o">=</span> <span class="n">bounds</span><span class="o">.</span><span class="n">minY</span>
    <span class="k">var</span> <span class="nv">lineHeight</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="k">for</span> <span class="n">subview</span> <span class="k">in</span> <span class="n">subviews</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">size</span> <span class="o">=</span> <span class="n">subview</span><span class="o">.</span><span class="nf">sizeThatFits</span><span class="p">(</span><span class="o">.</span><span class="n">unspecified</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">x</span> <span class="o">&gt;</span> <span class="n">bounds</span><span class="o">.</span><span class="n">minX</span><span class="p">,</span> <span class="n">x</span> <span class="o">+</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">&gt;</span> <span class="n">bounds</span><span class="o">.</span><span class="n">maxX</span> <span class="p">{</span>
            <span class="n">x</span> <span class="o">=</span> <span class="n">bounds</span><span class="o">.</span><span class="n">minX</span>
            <span class="n">y</span> <span class="o">+=</span> <span class="n">lineHeight</span> <span class="o">+</span> <span class="n">spacing</span>
            <span class="n">lineHeight</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="p">}</span>

        <span class="n">subview</span><span class="o">.</span><span class="nf">place</span><span class="p">(</span>
            <span class="nv">at</span><span class="p">:</span> <span class="kt">CGPoint</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="n">x</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="n">y</span><span class="p">),</span>
            <span class="nv">proposal</span><span class="p">:</span> <span class="kt">ProposedViewSize</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>
        <span class="p">)</span>

        <span class="n">x</span> <span class="o">+=</span> <span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">+</span> <span class="n">spacing</span>
        <span class="n">lineHeight</span> <span class="o">=</span> <span class="nf">max</span><span class="p">(</span><span class="n">lineHeight</span><span class="p">,</span> <span class="n">size</span><span class="o">.</span><span class="n">height</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The logic intentionally matches <code class="language-plaintext highlighter-rouge">sizeThatFits</code>. If measuring and placement use different wrapping rules, SwiftUI may reserve one height and then place the words as if it had another, which can produce clipping or unexpected empty space.</p>

<p>Here, <code class="language-plaintext highlighter-rouge">bounds.minX</code> and <code class="language-plaintext highlighter-rouge">bounds.minY</code> are also important. The placement coordinates belong to the bounds given to our layout, so we should not assume that its origin is always <code class="language-plaintext highlighter-rouge">(0, 0)</code>.</p>

<p>Now we can use <code class="language-plaintext highlighter-rouge">TranscriptLayout</code> almost like a standard SwiftUI stack:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">TranscriptLayout</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">ForEach</span><span class="p">(</span><span class="n">words</span><span class="p">)</span> <span class="p">{</span> <span class="n">word</span> <span class="k">in</span>
        <span class="c1">// Word button</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The result looks like one wrapping paragraph, but every word remains a real, independently accessible button.</p>
<h2 id="seeking-to-the-selected-word">Seeking to the selected word</h2>

<p>The final step is small because the timing information has already done most of the work for us.</p>

<p>The transcript view sends the selected word’s start time upward:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">guard</span> <span class="k">let</span> <span class="nv">startTime</span> <span class="o">=</span> <span class="n">word</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">?</span><span class="o">.</span><span class="n">start</span> <span class="k">else</span> <span class="p">{</span>
    <span class="k">return</span>
<span class="p">}</span>

<span class="nf">onSelectWord</span><span class="p">(</span><span class="n">startTime</span><span class="p">)</span>
</code></pre></div></div>

<p>The parent passes the view model’s seek method as that closure:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">TranscriptContainerView</span><span class="p">(</span>
    <span class="nv">transcript</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">transcript</span><span class="p">,</span>
    <span class="nv">words</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">transcriptWords</span><span class="p">,</span>
    <span class="nv">playbackTime</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">playbackTime</span><span class="p">,</span>
    <span class="nv">onSelectWord</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">seek</span>
<span class="p">)</span>
</code></pre></div></div>

<p>And the view model asks the player to seek to the supplied <code class="language-plaintext highlighter-rouge">CMTime</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">seek</span><span class="p">(</span><span class="n">to</span> <span class="nv">time</span><span class="p">:</span> <span class="kt">CMTime</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">player</span><span class="p">?</span><span class="o">.</span><span class="nf">seek</span><span class="p">(</span>
        <span class="nv">to</span><span class="p">:</span> <span class="n">time</span><span class="p">,</span>
        <span class="nv">toleranceBefore</span><span class="p">:</span> <span class="o">.</span><span class="n">zero</span><span class="p">,</span>
        <span class="nv">toleranceAfter</span><span class="p">:</span> <span class="o">.</span><span class="n">zero</span>
    <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>By default, media seeking can use a tolerance around the requested position. That is useful for efficient playback because landing on a nearby frame can be faster than decoding forward to an exact time.</p>

<p>For word selection, however, jumping noticeably before or after the word would make the transcript feel inaccurate. We therefore pass zero tolerance on both sides and ask <code class="language-plaintext highlighter-rouge">AVPlayer</code> to seek as precisely as the media allows.</p>

<p>There is still a tradeoff here. Exact seeking may take more work than approximate seeking, especially with videos that have widely spaced keyframes. For this interaction, I prefer accuracy because the user selected a specific spoken word. In another interface, such as quickly scrubbing through thumbnails, a nonzero tolerance might be the better choice.</p>

<p>The periodic time observer from Part 1 continues to update <code class="language-plaintext highlighter-rouge">playbackTime</code> after the seek. That means we do not need to set the highlighted word manually. The player moves, its observer reports the new time, and SwiftUI highlights whichever word contains that time.</p>

<h2 id="accessibility-details">Accessibility details</h2>

<p>Because every word is now a button, VoiceOver should also understand what selecting it will do.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">accessibilityLabel</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="n">word</span><span class="o">.</span><span class="n">word</span><span class="o">.</span><span class="n">characters</span><span class="p">))</span>
<span class="o">.</span><span class="nf">accessibilityHint</span><span class="p">(</span><span class="s">"Seeks the video to this word"</span><span class="p">)</span>
<span class="o">.</span><span class="nf">accessibilityValue</span><span class="p">(</span>
    <span class="n">isHighlighted</span> <span class="p">?</span> <span class="s">"Currently speaking"</span> <span class="p">:</span> <span class="s">""</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The label provides the spoken word, the hint explains the action, and the value identifies the word that currently matches playback.</p>

<p>This is another advantage of using actual <code class="language-plaintext highlighter-rouge">Button</code> views instead of adding a tap gesture to plain text. We get button semantics and keyboard or accessibility activation behavior without recreating them ourselves.</p>

<h2 id="putting-everything-together">Putting everything together</h2>

<section class="post-video-intro post-video-intro--compact">
  <div class="post-video-intro__text">
    <p>The complete flow for this part looks like this:</p>

    <ol>
      <li><code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> returns an <code class="language-plaintext highlighter-rouge">AttributedString</code> with an <code class="language-plaintext highlighter-rouge">audioTimeRange</code> attached to each timed word.</li>
      <li>We split its characters into <code class="language-plaintext highlighter-rouge">AttributedSubstring</code> values so those attributes are preserved.</li>
      <li>A custom SwiftUI <code class="language-plaintext highlighter-rouge">Layout</code> displays the word buttons as a wrapping paragraph.</li>
      <li>Tapping a word sends its starting <code class="language-plaintext highlighter-rouge">CMTime</code> to the view model.</li>
      <li><code class="language-plaintext highlighter-rouge">AVPlayer</code> seeks to that time, and the existing time observer updates the highlighted word.</li>
    </ol>

    <p>We now have a transcript that works in both directions. Playing or seeking the video updates the transcript, and selecting the transcript updates the video.</p>

    <p><a href="/assets/downloads/video-editor-part-2.zip" download="">Download</a> project after changes here in part 2.</p>
  </div>

  <figure class="post-video-intro__media">
    <video controls="" playsinline="" preload="metadata">
      <source src="/assets/images/video-editor-part-2-result.MP4" type="video/mp4" />
      Your browser does not support embedded videos.
    </video>
    <figcaption>
      Final Result.(Well, screen touches are not visible due to recording on a real device, `SpeechAnalyzer` does not work on simulator. So assume that I press on the word that video jumps to.)
    </figcaption>
  </figure>
</section>

<p>In the next part, we will use these timed words to create subtitle groups and place them over the video. And after that, maybe will look at censoring some selected words on the video, I’m not sure yet:)</p>

<p>Thanks for reading, see you in the part 3!</p>]]></content><author><name>Alpay Calalli</name></author><category term="swift" /><category term="swiftui" /><category term="speech" /><category term="speech-to-text" /><category term="avfoundation" /><summary type="html"><![CDATA[In Part 1, we imported a video, transcribed its audio fully on-device, and used the timing attributes returned by SpeechTranscriber to highlight the currently spoken word.]]></summary></entry><entry><title type="html">Building an On-Device Video Editor app with Swift/SwiftUI and SpeechAnalyzer — Part 1</title><link href="https://alpayc.com/blog/2026/08/17/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-1" rel="alternate" type="text/html" title="Building an On-Device Video Editor app with Swift/SwiftUI and SpeechAnalyzer — Part 1" /><published>2026-08-17T12:00:00+00:00</published><updated>2026-08-17T12:00:00+00:00</updated><id>https://alpayc.com/blog/2026/08/17/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-1</id><content type="html" xml:base="https://alpayc.com/blog/2026/08/17/On-Device-Video-Transcriber-And-Auto-Sub-Generator-App-Part-1"><![CDATA[<section class="post-video-intro">
  <div class="post-video-intro__text">
    <p>Hi everyone, this is going to be the first part of a few-part series where I’m going to share my experience of building kind of a video editor app where user can see the full transcription of the video, see auto-generated subtitles on it, and even select words to censor(add beep or duck sound). It will work fully on-device, thanks to Apple’s SpeechAnalyzer API for speech-to-text conversion.</p>

    <p>Actually, I built the final version of this app as my submission for the Swift Student Challenge this year, which didn’t turn out to be selected among the winners. Still, after some time passed, I thought it was a cool app where I tried and learned different technologies, and it would be nice to share my experiences and how I solved some technical barriers.</p>
  </div>

  <figure class="post-video-intro__media">
    <video controls="" playsinline="" preload="metadata">
      <source src="/assets/images/video-editor-demo.mp4" type="video/mp4" />
      Your browser does not support embedded videos.
    </video>
    <figcaption>
      A preview of the final app, including transcription, subtitles, and word censoring.
    </figcaption>
  </figure>
</section>

<p>There are several interesting parts we will take a look at, such as muting selected words with high precision to not cause any odd behaviour or adding layers at specific positions in an export video which is unfortunately, is not as straightforward as adding an <code class="language-plaintext highlighter-rouge">.overlay</code> modifier in SwiftUI.</p>

<p>We will get to those in the following parts of the series.</p>

<p>In this first part, we will look at how to:</p>

<ul>
  <li>Import a video from Files or the Photo Library.</li>
  <li>Transcribe the audio fully on-device.</li>
  <li>Display the video using SwiftUI’s native <code class="language-plaintext highlighter-rouge">VideoPlayer</code>.</li>
  <li>Highlight the currently spoken word as the video plays or the user changes its playback position.</li>
</ul>

<p>I haven’t included all the code in this post to keep the code snippets minimal. You can download the complete Xcode project for Part 1 <a href="/assets/downloads/video-editor-part-1.zip" download="">here</a>.</p>

<h2 id="creating-the-transcriber">Creating the transcriber</h2>

<p>Here is our <code class="language-plaintext highlighter-rouge">Transcriber</code> class, which accepts the URL of an audio file, transcribes it, and returns the final result as an <code class="language-plaintext highlighter-rouge">AttributedString</code>.</p>

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

<span class="kd">protocol</span> <span class="kt">Transcribable</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">transcribe</span><span class="p">(</span><span class="n">audioAt</span> <span class="nv">url</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">AttributedString</span>
<span class="p">}</span>

<span class="kd">final</span> <span class="kd">class</span> <span class="kt">Transcriber</span><span class="p">:</span> <span class="kt">Transcribable</span> <span class="p">{</span>
    <span class="kd">private</span> <span class="k">let</span> <span class="nv">locale</span> <span class="o">=</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="s">"en-US"</span><span class="p">)</span>

    <span class="kd">func</span> <span class="nf">transcribe</span><span class="p">(</span><span class="n">audioAt</span> <span class="nv">url</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">AttributedString</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="kt">SpeechTranscriber</span><span class="o">.</span><span class="n">isAvailable</span> <span class="k">else</span> <span class="p">{</span>
            <span class="k">throw</span> <span class="kt">TranscriptError</span><span class="o">.</span><span class="n">notAvailable</span>
        <span class="p">}</span>

        <span class="k">let</span> <span class="nv">transcriber</span> <span class="o">=</span> <span class="kt">SpeechTranscriber</span><span class="p">(</span>
            <span class="nv">locale</span><span class="p">:</span> <span class="n">locale</span><span class="p">,</span>
            <span class="nv">transcriptionOptions</span><span class="p">:</span> <span class="p">[],</span>
            <span class="nv">reportingOptions</span><span class="p">:</span> <span class="p">[],</span>
            <span class="nv">attributeOptions</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">]</span>
        <span class="p">)</span>

        <span class="k">let</span> <span class="nv">analyzer</span> <span class="o">=</span> <span class="kt">SpeechAnalyzer</span><span class="p">(</span><span class="nv">modules</span><span class="p">:</span> <span class="p">[</span><span class="n">transcriber</span><span class="p">])</span>
        <span class="k">let</span> <span class="nv">audioFile</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">AVAudioFile</span><span class="p">(</span><span class="nv">forReading</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>

        <span class="k">if</span> <span class="k">let</span> <span class="nv">lastSample</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">analyzer</span><span class="o">.</span><span class="nf">analyzeSequence</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">audioFile</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">try</span> <span class="k">await</span> <span class="n">analyzer</span><span class="o">.</span><span class="nf">finalizeAndFinish</span><span class="p">(</span><span class="nv">through</span><span class="p">:</span> <span class="n">lastSample</span><span class="p">)</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="k">await</span> <span class="n">analyzer</span><span class="o">.</span><span class="nf">cancelAndFinishNow</span><span class="p">()</span>
        <span class="p">}</span>

        <span class="k">var</span> <span class="nv">transcription</span> <span class="o">=</span> <span class="kt">AttributedString</span><span class="p">()</span>

        <span class="k">for</span> <span class="k">try</span> <span class="k">await</span> <span class="n">result</span> <span class="k">in</span> <span class="n">transcriber</span><span class="o">.</span><span class="n">results</span> <span class="k">where</span> <span class="n">result</span><span class="o">.</span><span class="n">isFinal</span> <span class="p">{</span>
            <span class="n">transcription</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">result</span><span class="o">.</span><span class="n">text</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">guard</span> <span class="o">!</span><span class="n">transcription</span><span class="o">.</span><span class="n">characters</span><span class="o">.</span><span class="n">isEmpty</span> <span class="k">else</span> <span class="p">{</span>
            <span class="k">throw</span> <span class="kt">TranscriptError</span><span class="o">.</span><span class="n">emptyTranscript</span>
        <span class="p">}</span>

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

<p>Let’s go through some of the code here.</p>

<p>We check the availability of <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> for the user’s device by putting a guard statement with <code class="language-plaintext highlighter-rouge">SpeechTranscriber.isAvailable</code> at the very beginning.</p>

<p>This property tells us whether the transcriber module is available based on the device’s hardware and capabilities.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">guard</span> <span class="kt">SpeechTranscriber</span><span class="o">.</span><span class="n">isAvailable</span> <span class="k">else</span> <span class="p">{</span>
    <span class="k">throw</span> <span class="kt">TranscriptError</span><span class="o">.</span><span class="n">notAvailable</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If it is unavailable, you can disable this feature or fall back to <a href="https://developer.apple.com/documentation/speech/dictationtranscriber"><code class="language-plaintext highlighter-rouge">DictationTranscriber</code></a>. In this article, however, we are going to use the new <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> API.</p>

<p>This demo assumes that the on-device speech model for <code class="language-plaintext highlighter-rouge">en-US</code> is already installed. In a production app, you should also handle cases where the required model is unavailable.</p>

<h2 id="configuring-speechtranscriber">Configuring SpeechTranscriber</h2>

<p>After checking its availability, we create a <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> for the locale we want to support.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">transcriber</span> <span class="o">=</span> <span class="kt">SpeechTranscriber</span><span class="p">(</span>
    <span class="nv">locale</span><span class="p">:</span> <span class="n">locale</span><span class="p">,</span>
    <span class="nv">transcriptionOptions</span><span class="p">:</span> <span class="p">[],</span>
    <span class="nv">reportingOptions</span><span class="p">:</span> <span class="p">[],</span>
    <span class="nv">attributeOptions</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">]</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The most important option for our use case is:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">attributeOptions</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">audioTimeRange</span><span class="p">]</span>
</code></pre></div></div>

<p>Without this option, we would still receive the transcribed text, but we would not know when each word was spoken.</p>

<p>With <code class="language-plaintext highlighter-rouge">.audioTimeRange</code>, the returned <code class="language-plaintext highlighter-rouge">AttributedString</code> contains an <code class="language-plaintext highlighter-rouge">audioTimeRange</code> attribute for its timed ranges. That timing information is what will later allow us to compare every word with the current playback time of the video.</p>

<p>This is also why we return an <code class="language-plaintext highlighter-rouge">AttributedString</code> instead of converting the result into a regular <code class="language-plaintext highlighter-rouge">String</code>. A regular <code class="language-plaintext highlighter-rouge">String</code> would preserve the characters, but it would lose the Speech framework’s timing attributes.</p>

<h2 id="analyzing-the-file">Analyzing the file</h2>

<p><code class="language-plaintext highlighter-rouge">SpeechAnalyzer</code> manages the analysis session, while <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> is the module that performs speech-to-text processing.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">analyzer</span> <span class="o">=</span> <span class="kt">SpeechAnalyzer</span><span class="p">(</span><span class="nv">modules</span><span class="p">:</span> <span class="p">[</span><span class="n">transcriber</span><span class="p">])</span>
<span class="k">let</span> <span class="nv">audioFile</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">AVAudioFile</span><span class="p">(</span><span class="nv">forReading</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
</code></pre></div></div>

<p>We then give the audio file to the analyzer:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="k">let</span> <span class="nv">lastSample</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">analyzer</span><span class="o">.</span><span class="nf">analyzeSequence</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">audioFile</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">try</span> <span class="k">await</span> <span class="n">analyzer</span><span class="o">.</span><span class="nf">finalizeAndFinish</span><span class="p">(</span><span class="nv">through</span><span class="p">:</span> <span class="n">lastSample</span><span class="p">)</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="k">await</span> <span class="n">analyzer</span><span class="o">.</span><span class="nf">cancelAndFinishNow</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There is a small but important detail here.</p>

<p><code class="language-plaintext highlighter-rouge">analyzeSequence(from:)</code> returns after the file has been read, but the last audio samples may still be undergoing analysis. Its return value is the time of the last sample that was read.</p>

<p>Passing that value to <code class="language-plaintext highlighter-rouge">finalizeAndFinish(through:)</code> tells the analyzer to finish processing everything through that point before we continue.</p>

<p>Apple explains this behavior in the documentation for <a href="https://developer.apple.com/documentation/speech/speechanalyzer/analyzesequence%28from%3A%29"><code class="language-plaintext highlighter-rouge">analyzeSequence(from:)</code></a>.</p>

<p>Finally, transcription results are delivered asynchronously through <code class="language-plaintext highlighter-rouge">transcriber.results</code>.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">transcription</span> <span class="o">=</span> <span class="kt">AttributedString</span><span class="p">()</span>

<span class="k">for</span> <span class="k">try</span> <span class="k">await</span> <span class="n">result</span> <span class="k">in</span> <span class="n">transcriber</span><span class="o">.</span><span class="n">results</span> <span class="k">where</span> <span class="n">result</span><span class="o">.</span><span class="n">isFinal</span> <span class="p">{</span>
    <span class="n">transcription</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">result</span><span class="o">.</span><span class="n">text</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For a prerecorded video, we only need finalized results. Volatile results are more useful for live transcription, where you want to display SpeechTranscriber’s latest guess while the person is still speaking.</p>

<h2 id="view-setup">View setup</h2>
<p>For this demo, the user can select a video from either Files or the Photo Library.</p>

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

<span class="kd">struct</span> <span class="kt">TranscriptDemoView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">model</span> <span class="o">=</span> <span class="kt">TranscriptDemoViewModel</span><span class="p">()</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isFileImporterPresented</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isPhotoPickerPresented</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">photoPickerItem</span><span class="p">:</span> <span class="kt">PhotosPickerItem</span><span class="p">?</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">ScrollView</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">24</span><span class="p">)</span> <span class="p">{</span>
                <span class="k">if</span> <span class="k">let</span> <span class="nv">player</span> <span class="o">=</span> <span class="n">model</span><span class="o">.</span><span class="n">player</span> <span class="p">{</span>
                    <span class="kt">VideoPlayer</span><span class="p">(</span><span class="nv">player</span><span class="p">:</span> <span class="n">player</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">aspectRatio</span><span class="p">(</span><span class="mi">16</span> <span class="o">/</span> <span class="mi">9</span><span class="p">,</span> <span class="nv">contentMode</span><span class="p">:</span> <span class="o">.</span><span class="n">fit</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="o">.</span><span class="nf">rect</span><span class="p">(</span><span class="nv">cornerRadius</span><span class="p">:</span> <span class="mi">12</span><span class="p">))</span>

                    <span class="kt">TranscriptContainerView</span><span class="p">(</span>
                        <span class="nv">transcript</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">transcript</span><span class="p">,</span>
                        <span class="nv">playbackTime</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">playbackTime</span>
                    <span class="p">)</span>
                <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                    <span class="n">contentUnavailableView</span>
                <span class="p">}</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">padding</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="k">if</span> <span class="n">model</span><span class="o">.</span><span class="n">player</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
                <span class="n">videoImporterView</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">fileImporter</span><span class="p">(</span>
            <span class="nv">isPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">isFileImporterPresented</span><span class="p">,</span>
            <span class="nv">allowedContentTypes</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="n">movie</span><span class="p">],</span>
            <span class="nv">allowsMultipleSelection</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
            <span class="nv">onCompletion</span><span class="p">:</span> <span class="n">handleFileImport</span>
        <span class="p">)</span>
        <span class="o">.</span><span class="nf">photosPicker</span><span class="p">(</span>
            <span class="nv">isPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">isPhotoPickerPresented</span><span class="p">,</span>
            <span class="nv">selection</span><span class="p">:</span> <span class="err">$</span><span class="n">photoPickerItem</span><span class="p">,</span>
            <span class="nv">matching</span><span class="p">:</span> <span class="o">.</span><span class="n">videos</span>
        <span class="p">)</span>
        <span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">photoPickerItem</span><span class="p">,</span> <span class="n">handlePhotoSelection</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">onDisappear</span><span class="p">(</span><span class="nv">perform</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">stopPlayback</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">handleFileImport</span><span class="p">(</span><span class="n">_</span> <span class="nv">result</span><span class="p">:</span> <span class="kt">Result</span><span class="o">&lt;</span><span class="p">[</span><span class="kt">URL</span><span class="p">],</span> <span class="n">any</span> <span class="kt">Error</span><span class="o">&gt;</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">result</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="nf">success</span><span class="p">(</span><span class="k">let</span> <span class="nv">urls</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="n">urls</span><span class="o">.</span><span class="n">first</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
            <span class="kt">Task</span> <span class="p">{</span>
                <span class="k">await</span> <span class="n">model</span><span class="o">.</span><span class="nf">importVideo</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="k">case</span> <span class="o">.</span><span class="nf">failure</span><span class="p">(</span><span class="k">let</span> <span class="nv">error</span><span class="p">):</span>
            <span class="n">model</span><span class="o">.</span><span class="nf">reportFileImportFailure</span><span class="p">(</span><span class="n">error</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">handlePhotoSelection</span><span class="p">(</span><span class="n">_</span> <span class="nv">oldItem</span><span class="p">:</span> <span class="kt">PhotosPickerItem</span><span class="p">?,</span> <span class="n">_</span> <span class="nv">newItem</span><span class="p">:</span> <span class="kt">PhotosPickerItem</span><span class="p">?)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">newItem</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="kt">Task</span> <span class="p">{</span>
            <span class="k">await</span> <span class="n">model</span><span class="o">.</span><span class="nf">importVideo</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">newItem</span><span class="p">)</span>
            <span class="n">photoPickerItem</span> <span class="o">=</span> <span class="kc">nil</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">TranscriptDemoView</span> <span class="p">{</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">contentUnavailableView</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">ContentUnavailableView</span> <span class="p">{</span>
            <span class="kt">Label</span><span class="p">(</span><span class="s">"Import a Video"</span><span class="p">,</span> <span class="nv">systemImage</span><span class="p">:</span> <span class="s">"video.badge.plus"</span><span class="p">)</span>
        <span class="p">}</span> <span class="nv">description</span><span class="p">:</span> <span class="p">{</span>
            <span class="kt">Text</span><span class="p">(</span><span class="s">"Choose a video from Files or your Photo Library to create a word-timed transcript."</span><span class="p">)</span>
        <span class="p">}</span> <span class="nv">actions</span><span class="p">:</span> <span class="p">{</span>
            <span class="kt">VideoImportMenu</span><span class="p">(</span>
                <span class="nv">isFileImporterPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">isFileImporterPresented</span><span class="p">,</span>
                <span class="nv">isPhotoPickerPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">isPhotoPickerPresented</span><span class="p">,</span>
                <span class="nv">isDisabled</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">isProcessing</span>
            <span class="p">)</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">containerRelativeFrame</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">)</span> <span class="p">{</span> <span class="n">height</span><span class="p">,</span> <span class="n">_</span> <span class="k">in</span>
            <span class="n">height</span> <span class="o">*</span> <span class="mf">0.65</span>
        <span class="p">}</span>
    <span class="p">}</span>
    
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">videoImporterView</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">VideoImportMenu</span><span class="p">(</span>
            <span class="nv">isFileImporterPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">isFileImporterPresented</span><span class="p">,</span>
            <span class="nv">isPhotoPickerPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">isPhotoPickerPresented</span><span class="p">,</span>
            <span class="nv">isDisabled</span><span class="p">:</span> <span class="n">model</span><span class="o">.</span><span class="n">isProcessing</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>

<h2 id="viewmodel-setup">ViewModel setup</h2>

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

<span class="c1">/// Coordinates importing, audio extraction, transcription, and video playback.</span>
<span class="c1">///</span>
<span class="c1">/// The view owns this model, while the model exposes only UI-facing state. Media</span>
<span class="c1">/// processing details stay in the focused helper types below it.</span>
<span class="kd">@MainActor</span>
<span class="kd">@Observable</span>
<span class="kd">final</span> <span class="kd">class</span> <span class="kt">TranscriptDemoViewModel</span> <span class="p">{</span>
    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">player</span><span class="p">:</span> <span class="kt">AVPlayer</span><span class="p">?</span>
    <span class="c1">/// Speech's attributed result, including an `audioTimeRange` on each word.</span>
    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">transcript</span><span class="p">:</span> <span class="kt">AttributedString</span><span class="p">?</span>
    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">playbackTime</span><span class="p">:</span> <span class="kt">TimeInterval</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">state</span> <span class="o">=</span> <span class="kt">TranscriptState</span><span class="o">.</span><span class="n">empty</span>

    <span class="kd">@ObservationIgnored</span> <span class="kd">private</span> <span class="k">let</span> <span class="nv">audioExtractor</span> <span class="o">=</span> <span class="kt">VideoAudioExtractor</span><span class="p">()</span>
    <span class="kd">@ObservationIgnored</span> <span class="kd">private</span> <span class="k">let</span> <span class="nv">transcriber</span><span class="p">:</span> <span class="kt">Transcribable</span> <span class="o">=</span> <span class="kt">Transcriber</span><span class="p">()</span>
    <span class="kd">@ObservationIgnored</span> <span class="kd">private</span> <span class="k">let</span> <span class="nv">videoStore</span> <span class="o">=</span> <span class="kt">ImportedVideoStore</span><span class="p">()</span>
    <span class="kd">@ObservationIgnored</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">timeObserver</span><span class="p">:</span> <span class="kt">Any</span><span class="p">?</span>

    <span class="nf">init</span><span class="p">()</span> <span class="p">{</span>
        <span class="kt">AVPlayer</span><span class="o">.</span><span class="n">isObservationEnabled</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">isProcessing</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">isProcessing</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">importVideo</span><span class="p">(</span><span class="n">from</span> <span class="nv">fileURL</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">importing</span>

        <span class="c1">// File importer URLs require explicit access. Copy the video while access</span>
        <span class="c1">// is active, then release the external file immediately afterward.</span>
        <span class="k">guard</span> <span class="n">fileURL</span><span class="o">.</span><span class="nf">startAccessingSecurityScopedResource</span><span class="p">()</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</span><span class="p">(</span><span class="s">"The selected file could not be accessed."</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="p">}</span>

        <span class="k">defer</span> <span class="p">{</span>
            <span class="n">fileURL</span><span class="o">.</span><span class="nf">stopAccessingSecurityScopedResource</span><span class="p">()</span>
        <span class="p">}</span>

        <span class="k">do</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">localURL</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">videoStore</span><span class="o">.</span><span class="nf">copyVideo</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">fileURL</span><span class="p">)</span>
            <span class="k">try</span> <span class="k">await</span> <span class="nf">processVideo</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">localURL</span><span class="p">)</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</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="p">}</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">importVideo</span><span class="p">(</span><span class="n">from</span> <span class="nv">item</span><span class="p">:</span> <span class="kt">PhotosPickerItem</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">importing</span>

        <span class="k">do</span> <span class="p">{</span>
            <span class="k">guard</span> <span class="k">let</span> <span class="nv">video</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">item</span><span class="o">.</span><span class="nf">loadTransferable</span><span class="p">(</span><span class="nv">type</span><span class="p">:</span> <span class="kt">TransferableVideo</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span>
                <span class="k">throw</span> <span class="kt">TranscriptError</span><span class="o">.</span><span class="n">photoImportFailed</span>
            <span class="p">}</span>

            <span class="k">try</span> <span class="k">await</span> <span class="nf">processVideo</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">url</span><span class="p">)</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</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="p">}</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">reportFileImportFailure</span><span class="p">(</span><span class="n">_</span> <span class="nv">error</span><span class="p">:</span> <span class="n">any</span> <span class="kt">Error</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</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="p">}</span>

    <span class="kd">func</span> <span class="nf">stopPlayback</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">player</span><span class="p">?</span><span class="o">.</span><span class="nf">pause</span><span class="p">()</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">processVideo</span><span class="p">(</span><span class="n">at</span> <span class="nv">videoURL</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="p">{</span>
        <span class="c1">// Playback can be prepared immediately; transcription does not need to</span>
        <span class="c1">// finish before the user can inspect the selected video.</span>
        <span class="nf">preparePlayer</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">videoURL</span><span class="p">)</span>
        <span class="n">transcript</span> <span class="o">=</span> <span class="kc">nil</span>
        <span class="n">playbackTime</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">extractingAudio</span>

        <span class="c1">// somehow, i remember that feeding video url to transcriber used to cause</span>
        <span class="c1">// some issue/throw error, but now it seems fine, so keeping both.</span>
        <span class="c1">// let audioURL = try await audioExtractor.extractAudio(from: videoURL)</span>
        <span class="k">let</span> <span class="nv">audioURL</span> <span class="o">=</span> <span class="n">videoURL</span>
        <span class="k">defer</span> <span class="p">{</span>
            <span class="c1">// The extracted track is an intermediate input, not user data.</span>
            <span class="k">try</span><span class="p">?</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="nf">removeItem</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">audioURL</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">transcribing</span>
        <span class="n">transcript</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">transcriber</span><span class="o">.</span><span class="nf">transcribe</span><span class="p">(</span><span class="nv">audioAt</span><span class="p">:</span> <span class="n">audioURL</span><span class="p">)</span>
        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">ready</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">preparePlayer</span><span class="p">(</span><span class="k">for</span> <span class="nv">videoURL</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// An observer belongs to the player that created it, so remove the old</span>
        <span class="c1">// token before replacing the player with one for a newly imported video.</span>
        <span class="nf">removeTimeObserver</span><span class="p">()</span>
        <span class="n">player</span><span class="p">?</span><span class="o">.</span><span class="nf">pause</span><span class="p">()</span>

        <span class="k">let</span> <span class="nv">player</span> <span class="o">=</span> <span class="kt">AVPlayer</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">videoURL</span><span class="p">)</span>
        <span class="k">self</span><span class="o">.</span><span class="n">player</span> <span class="o">=</span> <span class="n">player</span>

        <span class="c1">// VideoPlayer owns play, pause, and seeking controls. This observer</span>
        <span class="c1">// mirrors its clock into observable state so the transcript follows them.</span>
        <span class="k">let</span> <span class="nv">interval</span> <span class="o">=</span> <span class="kt">CMTime</span><span class="p">(</span><span class="nv">seconds</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">preferredTimescale</span><span class="p">:</span> <span class="mi">600</span><span class="p">)</span>
        <span class="n">timeObserver</span> <span class="o">=</span> <span class="n">player</span><span class="o">.</span><span class="nf">addPeriodicTimeObserver</span><span class="p">(</span><span class="nv">forInterval</span><span class="p">:</span> <span class="n">interval</span><span class="p">,</span> <span class="nv">queue</span><span class="p">:</span> <span class="o">.</span><span class="n">main</span><span class="p">)</span> <span class="p">{</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="n">time</span> <span class="k">in</span>
            <span class="kt">Task</span> <span class="p">{</span> <span class="kd">@MainActor</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="k">in</span>
                <span class="k">self</span><span class="p">?</span><span class="o">.</span><span class="n">playbackTime</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">seconds</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">removeTimeObserver</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">player</span><span class="p">,</span> <span class="k">let</span> <span class="nv">timeObserver</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">player</span><span class="o">.</span><span class="nf">removeTimeObserver</span><span class="p">(</span><span class="n">timeObserver</span><span class="p">)</span>
        <span class="k">self</span><span class="o">.</span><span class="n">timeObserver</span> <span class="o">=</span> <span class="kc">nil</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<p>There is one thing we should keep in mind here: the URLs received from these importers may not remain available forever.</p>

<p>Files returns a security-scoped URL. We start accessing it, copy the video into our app’s temporary directory, and then stop accessing the original URL.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">guard</span> <span class="n">fileURL</span><span class="o">.</span><span class="nf">startAccessingSecurityScopedResource</span><span class="p">()</span> <span class="k">else</span> <span class="p">{</span>
    <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</span><span class="p">(</span><span class="s">"The selected file could not be accessed."</span><span class="p">)</span>
    <span class="k">return</span>
<span class="p">}</span>

<span class="k">defer</span> <span class="p">{</span>
    <span class="n">fileURL</span><span class="o">.</span><span class="nf">stopAccessingSecurityScopedResource</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">let</span> <span class="nv">localURL</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">videoStore</span><span class="o">.</span><span class="nf">copyVideo</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">fileURL</span><span class="p">)</span>
</code></pre></div></div>

<p>The file delivered by <code class="language-plaintext highlighter-rouge">PhotosPicker</code> is also temporary. Our <code class="language-plaintext highlighter-rouge">TransferableVideo</code> implementation copies it before the transfer closure returns.</p>

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

<span class="c1">/// A durable local copy of a video selected through `PhotosPicker`.</span>
<span class="c1">///</span>
<span class="c1">/// The URL supplied to a transfer closure is temporary. Copying the file before</span>
<span class="c1">/// the closure returns ensures AVPlayer and the audio extractor can use it later.</span>
<span class="kd">struct</span> <span class="kt">TransferableVideo</span><span class="p">:</span> <span class="kt">Transferable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">url</span><span class="p">:</span> <span class="kt">URL</span>

    <span class="kd">nonisolated</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">transferRepresentation</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">TransferRepresentation</span> <span class="p">{</span>
        <span class="kt">FileRepresentation</span><span class="p">(</span><span class="nv">importedContentType</span><span class="p">:</span> <span class="o">.</span><span class="n">movie</span><span class="p">)</span> <span class="p">{</span> <span class="n">receivedVideo</span> <span class="k">in</span>
            <span class="k">let</span> <span class="nv">sourceURL</span> <span class="o">=</span> <span class="n">receivedVideo</span><span class="o">.</span><span class="n">file</span>
            <span class="k">let</span> <span class="nv">destinationURL</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="o">.</span><span class="nf">appending</span><span class="p">(</span><span class="nv">path</span><span class="p">:</span> <span class="kt">UUID</span><span class="p">()</span><span class="o">.</span><span class="n">uuidString</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">appendingPathExtension</span><span class="p">(</span><span class="n">sourceURL</span><span class="o">.</span><span class="n">pathExtension</span><span class="p">)</span>

            <span class="k">try</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="nf">copyItem</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">sourceURL</span><span class="p">,</span> <span class="nv">to</span><span class="p">:</span> <span class="n">destinationURL</span><span class="p">)</span>
            <span class="k">return</span> <span class="kt">TransferableVideo</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">destinationURL</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>By doing this, <code class="language-plaintext highlighter-rouge">AVPlayer</code> and the transcriber both receive a local URL that remains valid while the demo is running.</p>

<p>Using <code class="language-plaintext highlighter-rouge">VideoPlayer</code> means we do not need to build our own play, pause, scrubbing, or fullscreen controls.</p>

<p>However, the transcript still needs to know the current playback time. For that, we added an observer to the player.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">interval</span> <span class="o">=</span> <span class="kt">CMTime</span><span class="p">(</span>
    <span class="nv">seconds</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span>
    <span class="nv">preferredTimescale</span><span class="p">:</span> <span class="mi">600</span>
<span class="p">)</span>

<span class="n">timeObserver</span> <span class="o">=</span> <span class="n">player</span><span class="o">.</span><span class="nf">addPeriodicTimeObserver</span><span class="p">(</span>
    <span class="nv">forInterval</span><span class="p">:</span> <span class="n">interval</span><span class="p">,</span>
    <span class="nv">queue</span><span class="p">:</span> <span class="o">.</span><span class="n">main</span>
<span class="p">)</span> <span class="p">{</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="n">time</span> <span class="k">in</span>
    <span class="kt">Task</span> <span class="p">{</span> <span class="kd">@MainActor</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="k">in</span>
        <span class="k">self</span><span class="p">?</span><span class="o">.</span><span class="n">playbackTime</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">seconds</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every time the observer runs, we copy the player’s time into observable state.</p>

<p>This works not only while the video is playing. If the user pauses the video or changes its position using the native controls, the observer receives the updated time and the transcript follows it.</p>

<p>Before replacing the player with another imported video, we also need to remove the previous observer:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">removeTimeObserver</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="k">let</span> <span class="nv">player</span><span class="p">,</span> <span class="k">let</span> <span class="nv">timeObserver</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="n">player</span><span class="o">.</span><span class="nf">removeTimeObserver</span><span class="p">(</span><span class="n">timeObserver</span><span class="p">)</span>
    <span class="k">self</span><span class="o">.</span><span class="n">timeObserver</span> <span class="o">=</span> <span class="kc">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A periodic time observer belongs to the player that created it. Keeping an observer after replacing its player can leave us with callbacks from an object we no longer use.</p>

<h2 id="highlighting-the-current-word">Highlighting the current word</h2>

<p>At this point, we have two pieces of information:</p>

<ul>
  <li>An <code class="language-plaintext highlighter-rouge">AttributedString</code> whose words contain <code class="language-plaintext highlighter-rouge">audioTimeRange</code> attributes.</li>
  <li>The current playback time of the video.</li>
</ul>

<p>Now we need to connect and synchronize them.</p>

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

<span class="kd">struct</span> <span class="kt">DynamicTranscriptTextView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">transcript</span><span class="p">:</span> <span class="kt">AttributedString</span>
    <span class="k">let</span> <span class="nv">playbackTime</span><span class="p">:</span> <span class="kt">TimeInterval</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">Text</span><span class="p">(</span><span class="n">highlightedTranscript</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">body</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">leading</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="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">accessibilityLabel</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="n">transcript</span><span class="o">.</span><span class="n">characters</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">highlightedTranscript</span><span class="p">:</span> <span class="kt">AttributedString</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">result</span> <span class="o">=</span> <span class="n">transcript</span>
        <span class="n">result</span><span class="o">.</span><span class="n">foregroundColor</span> <span class="o">=</span> <span class="o">.</span><span class="n">secondary</span>

        <span class="k">let</span> <span class="nv">currentTime</span> <span class="o">=</span> <span class="kt">CMTime</span><span class="p">(</span>
            <span class="nv">seconds</span><span class="p">:</span> <span class="n">playbackTime</span><span class="p">,</span>
            <span class="nv">preferredTimescale</span><span class="p">:</span> <span class="mi">600</span>
        <span class="p">)</span>

        <span class="k">let</span> <span class="nv">currentInstant</span> <span class="o">=</span> <span class="kt">CMTimeRange</span><span class="p">(</span>
            <span class="nv">start</span><span class="p">:</span> <span class="n">currentTime</span><span class="p">,</span>
            <span class="nv">duration</span><span class="p">:</span> <span class="kt">CMTime</span><span class="p">(</span><span class="nv">value</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">timescale</span><span class="p">:</span> <span class="mi">600</span><span class="p">)</span>
        <span class="p">)</span>

        <span class="k">guard</span> <span class="k">let</span> <span class="nv">currentWord</span> <span class="o">=</span> <span class="n">result</span>
            <span class="o">.</span><span class="nf">rangeOfAudioTimeRangeAttributes</span><span class="p">(</span>
                <span class="nv">intersecting</span><span class="p">:</span> <span class="n">currentInstant</span>
            <span class="p">)</span> <span class="k">else</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">result</span>
        <span class="p">}</span>

        <span class="n">result</span><span class="p">[</span><span class="n">currentWord</span><span class="p">]</span><span class="o">.</span><span class="n">foregroundColor</span> <span class="o">=</span> <span class="o">.</span><span class="n">primary</span>
        <span class="n">result</span><span class="p">[</span><span class="n">currentWord</span><span class="p">]</span><span class="o">.</span><span class="n">backgroundColor</span> <span class="o">=</span> <span class="o">.</span><span class="n">mint</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.35</span><span class="p">)</span>
        <span class="n">result</span><span class="p">[</span><span class="n">currentWord</span><span class="p">]</span><span class="o">.</span><span class="n">underlineStyle</span> <span class="o">=</span> <span class="o">.</span><span class="n">single</span>

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

<p>We begin by creating a copy of the transcript and applying the default secondary color.</p>

<p>Then we convert the player’s current time into <code class="language-plaintext highlighter-rouge">CMTime</code> and create a very small <code class="language-plaintext highlighter-rouge">CMTimeRange</code> starting at that position.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">currentInstant</span> <span class="o">=</span> <span class="kt">CMTimeRange</span><span class="p">(</span>
    <span class="nv">start</span><span class="p">:</span> <span class="n">currentTime</span><span class="p">,</span>
    <span class="nv">duration</span><span class="p">:</span> <span class="kt">CMTime</span><span class="p">(</span><span class="nv">value</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">timescale</span><span class="p">:</span> <span class="mi">600</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>This range represents one tick on a 600-timescale timeline. In other words, it gives us a tiny interval around the current playback position.</p>

<p>We then ask the attributed transcript for the range whose <code class="language-plaintext highlighter-rouge">audioTimeRange</code> intersects that interval:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">guard</span> <span class="k">let</span> <span class="nv">currentWord</span> <span class="o">=</span> <span class="n">result</span>
    <span class="o">.</span><span class="nf">rangeOfAudioTimeRangeAttributes</span><span class="p">(</span>
        <span class="nv">intersecting</span><span class="p">:</span> <span class="n">currentInstant</span>
    <span class="p">)</span> <span class="k">else</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">result</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If a matching word exists, we update only that attributed range.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">result</span><span class="p">[</span><span class="n">currentWord</span><span class="p">]</span><span class="o">.</span><span class="n">foregroundColor</span> <span class="o">=</span> <span class="o">.</span><span class="n">primary</span>
<span class="n">result</span><span class="p">[</span><span class="n">currentWord</span><span class="p">]</span><span class="o">.</span><span class="n">backgroundColor</span> <span class="o">=</span> <span class="o">.</span><span class="n">mint</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.35</span><span class="p">)</span>
<span class="n">result</span><span class="p">[</span><span class="n">currentWord</span><span class="p">]</span><span class="o">.</span><span class="n">underlineStyle</span> <span class="o">=</span> <span class="o">.</span><span class="n">single</span>
</code></pre></div></div>

<p>Because this view receives <code class="language-plaintext highlighter-rouge">playbackTime</code> from observable state, SwiftUI recalculates the attributed text whenever the player time changes. The highlighted range therefore moves through the transcript together with the video.</p>

<p>The nice part is that we do not need to create a separate array containing every word and its timestamp. SpeechTranscriber has already attached the timing information to the returned <code class="language-plaintext highlighter-rouge">AttributedString</code>.</p>

<p>Apple demonstrates the same general technique—comparing playback time with the <code class="language-plaintext highlighter-rouge">audioTimeRange</code> attributes—in its <a href="https://developer.apple.com/videos/play/wwdc2025/277/">WWDC25 SpeechAnalyzer session</a>.</p>

<h2 id="putting-everything-together">Putting everything together</h2>

<video class="post-result-video" controls="" playsinline="" preload="metadata">
  <source src="/assets/images/video-editor-part-1-result.mp4" type="video/mp4" />
  Your browser does not support embedded videos.
</video>

<p>The complete flow now looks like this:</p>

<ol>
  <li>The user imports the video.</li>
  <li><code class="language-plaintext highlighter-rouge">SpeechAnalyzer</code> and <code class="language-plaintext highlighter-rouge">SpeechTranscriber</code> process its audio on-device.</li>
  <li>We keep the returned <code class="language-plaintext highlighter-rouge">AttributedString</code>, including its word-timing attributes.</li>
  <li>We compare those audio ranges with the current playback time and highlight the matching word.</li>
</ol>

<p>And that gives us a fully on-device video transcript that follows the native video player, including when the user pauses or seeks to another position.</p>

<p>In the next part, we will look at generating subtitle groups and placing them over the video. After that, we will look at the more complicated part: rendering those layers into the exported video itself.</p>

<p>Thanks for reading, see you in the next part!</p>]]></content><author><name>Alpay Calalli</name></author><category term="swift" /><category term="swiftui" /><category term="speech" /><category term="speech-to-text" /><summary type="html"><![CDATA[Hi everyone, this is going to be the first part of a few-part series where I’m going to share my experience of building kind of a video editor app where user can see the full transcription of the video, see auto-generated subtitles on it, and even select words to censor(add beep or duck sound). It will work fully on-device, thanks to Apple’s SpeechAnalyzer API for speech-to-text conversion.]]></summary></entry><entry><title type="html">How I managed requsting reviews in Treaty</title><link href="https://alpayc.com/blog/2025/06/21/how-i-managed-requsting-reviews-in-treaty" rel="alternate" type="text/html" title="How I managed requsting reviews in Treaty" /><published>2025-06-21T12:00:00+00:00</published><updated>2025-06-21T12:00:00+00:00</updated><id>https://alpayc.com/blog/2025/06/21/requesting-review-wisely</id><content type="html" xml:base="https://alpayc.com/blog/2025/06/21/how-i-managed-requsting-reviews-in-treaty"><![CDATA[<p>Requesting reviews and gathering feedback from users is an important part of all apps.</p>

<p><img src="/assets/images/requestreview-img.jpg" alt="Request Review Preview" style="width: 350px; max-width: 100%;" /></p>

<p>Apple allows you to show this alert to users only up to 3 times within a 365-day period, so it’s important to use it wisely.</p>

<p>I’ll show how I managed requesting reviews in <a href="https://apps.apple.com/az/app/treaty-dog-health/id6743374042">Treaty - Dog Health</a> by building a similar structure to <a href="https://developer.apple.com/documentation/tipkit/">TipKit</a>.</p>

<hr />

<p>Before starting, let’s set the rules:</p>

<p>Don’t show the alert more than once</p>
<ul>
  <li>in the same app version</li>
  <li>within a certain period (I will set it to 7 days)</li>
</ul>

<hr />

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">actor</span> <span class="kt">RequestReviewManager</span> <span class="p">{</span>
   <span class="kd">private</span> <span class="k">let</span> <span class="nv">userDefaults</span><span class="p">:</span> <span class="kt">UserDefaults</span>
   <span class="kd">private</span> <span class="k">let</span> <span class="nv">lastRequestReviewKey</span> <span class="o">=</span> <span class="s">"lastRequestReviewDate"</span>
   <span class="kd">private</span> <span class="k">let</span> <span class="nv">lastVersionPromptedKey</span> <span class="o">=</span> <span class="s">"lastVersionPromptedForReview"</span>
   
   <span class="kd">private</span> <span class="k">var</span> <span class="nv">lastRequestDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span> <span class="p">{</span> <span class="n">userDefaults</span><span class="o">.</span><span class="nf">value</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="n">lastRequestReviewKey</span><span class="p">)</span> <span class="k">as?</span> <span class="kt">Date</span> <span class="p">}</span>
   <span class="kd">private</span> <span class="k">var</span> <span class="nv">lastVersionPrompted</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span> <span class="p">{</span> <span class="n">userDefaults</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">lastVersionPromptedKey</span><span class="p">)</span> <span class="p">}</span>
   
   <span class="kd">private</span> <span class="k">var</span> <span class="nv">currentAppVersion</span><span class="p">:</span> <span class="kt">String</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">releaseVersionNumber</span>

   <span class="nf">init</span><span class="p">(</span><span class="nv">userDefaults</span><span class="p">:</span> <span class="kt">UserDefaults</span> <span class="o">=</span> <span class="o">.</span><span class="n">group</span><span class="p">)</span> <span class="p">{</span> <span class="k">self</span><span class="o">.</span><span class="n">userDefaults</span> <span class="o">=</span> <span class="n">userDefaults</span> <span class="p">}</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">Bundle</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">releaseVersionNumber</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
       <span class="k">return</span> <span class="n">infoDictionary</span><span class="p">?[</span><span class="s">"CFBundleShortVersionString"</span><span class="p">]</span> <span class="k">as?</span> <span class="kt">String</span> <span class="p">??</span> <span class="s">"1.0.0"</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here are all the properties we need.</p>

<p>Now, obviously, I want to show the alert to users at the most appropriate times.
Enum is a nice choice to define our showing cases.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">RequestReviewReason</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
   <span class="k">case</span> <span class="n">loggedEnoughFeedActivities</span>
   <span class="k">case</span> <span class="n">loggedEnoughHygieneActivities</span>
   <span class="k">case</span> <span class="n">endedWalk</span>
   
   <span class="k">var</span> <span class="nv">triggerValue</span><span class="p">:</span> <span class="kt">Int</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="n">loggedEnoughFeedActivities</span><span class="p">,</span> <span class="o">.</span><span class="nv">loggedEnoughHygieneActivities</span><span class="p">:</span> <span class="mi">3</span>
         <span class="k">case</span> <span class="o">.</span><span class="nv">endedWalk</span><span class="p">:</span> <span class="mi">1</span>
      <span class="p">}</span>
   <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

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

<span class="kd">actor</span> <span class="kt">RequestReviewManager</span> <span class="p">{</span>
   <span class="cm">/*...*/</span>
   <span class="kd">func</span> <span class="nf">donate</span><span class="p">(</span><span class="n">to</span> <span class="nv">reason</span><span class="p">:</span> <span class="kt">RequestReviewReason</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
      <span class="c1">// Don't show the alert, if it's already shown within 7 days.</span>
      <span class="k">if</span> <span class="k">let</span> <span class="nv">lastRequestDate</span><span class="p">,</span> <span class="n">lastRequestDate</span><span class="o">.</span><span class="nf">numberOfDays</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="o">.</span><span class="n">now</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">7</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
      
      <span class="c1">// Don't show the alert, if it's already shown in current app version.</span>
      <span class="k">if</span> <span class="k">let</span> <span class="nv">lastVersionPrompted</span><span class="p">,</span> <span class="n">lastVersionPrompted</span> <span class="o">==</span> <span class="n">currentAppVersion</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
      
      <span class="k">let</span> <span class="nv">count</span> <span class="o">=</span> <span class="n">userDefaults</span><span class="o">.</span><span class="nf">integer</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="n">reason</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
      <span class="k">if</span> <span class="n">count</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">==</span> <span class="n">reason</span><span class="o">.</span><span class="n">triggerValue</span> <span class="p">{</span>
         <span class="k">guard</span> <span class="k">let</span> <span class="nv">scene</span> <span class="o">=</span> <span class="k">await</span> <span class="kt">UIApplication</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">foregroundActiveScene</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
         <span class="k">await</span> <span class="kt">AppStore</span><span class="o">.</span><span class="nf">requestReview</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="n">scene</span><span class="p">)</span>
         
         <span class="n">userDefaults</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="kt">Date</span><span class="o">.</span><span class="n">now</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">lastRequestReviewKey</span><span class="p">)</span>
         <span class="n">userDefaults</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">currentAppVersion</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">lastVersionPromptedKey</span><span class="p">)</span>
      <span class="p">}</span>
      <span class="n">userDefaults</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">count</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">reason</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
   <span class="p">}</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">UIApplication</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">foregroundActiveScene</span><span class="p">:</span> <span class="kt">UIWindowScene</span><span class="p">?</span> <span class="p">{</span>
        <span class="n">connectedScenes</span>
            <span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">activationState</span> <span class="o">==</span> <span class="o">.</span><span class="n">foregroundActive</span> <span class="p">})</span> <span class="k">as?</span> <span class="kt">UIWindowScene</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">Date</span> <span class="p">{</span>
   <span class="kd">func</span> <span class="nf">numberOfDays</span><span class="p">(</span><span class="n">from</span> <span class="nv">date</span><span class="p">:</span> <span class="kt">Date</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Int</span> <span class="p">{</span>
      <span class="k">let</span> <span class="nv">calendar</span> <span class="o">=</span> <span class="kt">Calendar</span><span class="o">.</span><span class="n">current</span>
      <span class="k">let</span> <span class="nv">fromDate</span> <span class="o">=</span> <span class="n">calendar</span><span class="o">.</span><span class="nf">startOfDay</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="k">self</span><span class="p">)</span>
      <span class="k">let</span> <span class="nv">toDate</span> <span class="o">=</span> <span class="n">calendar</span><span class="o">.</span><span class="nf">startOfDay</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">date</span><span class="p">)</span>
      <span class="k">let</span> <span class="nv">numberOfDays</span> <span class="o">=</span> <span class="n">calendar</span><span class="o">.</span><span class="nf">dateComponents</span><span class="p">([</span><span class="o">.</span><span class="n">day</span><span class="p">],</span> <span class="nv">from</span><span class="p">:</span> <span class="n">fromDate</span><span class="p">,</span> <span class="nv">to</span><span class="p">:</span> <span class="n">toDate</span><span class="p">)</span>
      
      <span class="k">return</span> <span class="nf">abs</span><span class="p">(</span><span class="n">numberOfDays</span><span class="o">.</span><span class="n">day</span><span class="o">!</span><span class="p">)</span>
   <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>Ignoring donations and not incrementing the counter if the 7-day period hasn’t passed yet or this version has already been prompted for review.</p>

<p>You can change the waiting period for the next trigger, apply your own rules, etc.</p>

<p>Now, all we need to do is call the <code class="language-plaintext highlighter-rouge">donate()</code> method in the right places, and the alert will trigger if all conditions are met.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">FeedView</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">requestReviewManager</span><span class="p">)</span> <span class="k">var</span> <span class="nv">requestReviewManager</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="cm">/*...*/</span> <span class="p">}</span>
   <span class="kd">func</span> <span class="nf">addButtonTapped</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
      <span class="c1">// save action...</span>
      
      <span class="k">await</span> <span class="n">requestReviewManager</span><span class="o">.</span><span class="nf">donate</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="o">.</span><span class="n">loggedEnoughFeedActivities</span><span class="p">)</span>
   <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<video width="350" style="max-width: 100%; border-radius: 16px;" controls="">
  <source src="/assets/images/requestreview-preview.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<p>Thanks for reading!</p>]]></content><author><name>Alpay Calalli</name></author><summary type="html"><![CDATA[Requesting reviews and gathering feedback from users is an important part of all apps.]]></summary></entry><entry><title type="html">How to create a pull-to-trigger action for ScrollView in SwiftUI</title><link href="https://alpayc.com/blog/2024/11/06/howToCreateAPullToTriggerActionInSwiftUI" rel="alternate" type="text/html" title="How to create a pull-to-trigger action for ScrollView in SwiftUI" /><published>2024-11-06T12:00:00+00:00</published><updated>2024-11-06T12:00:00+00:00</updated><id>https://alpayc.com/blog/2024/11/06/howToCreateAPullToTriggerActionInSwiftUI</id><content type="html" xml:base="https://alpayc.com/blog/2024/11/06/howToCreateAPullToTriggerActionInSwiftUI"><![CDATA[<p>While I was working on making a performant calendar for my SwiftUI project, I was looking for a solution to load new dates as users scroll. Finally, decided to put only a single year’s data in a lazy container, and when you reach the end, pull to load more data, similar to “pull to refresh”.
The solutions I found for pull-to-trigger action in SwiftUI were using drag gestures to track changes, but encountered bugs and weird behaviors while using ScrollView + drag gestures, so looked for another solution.
With iOS 17, ScrollView has become more powerful with the addition of the missing APIs. The main modifier we’re going to use is scrollPosition(id:anchor:) to track the current position.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">CalendarView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">scrollId</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span> <span class="o">=</span> <span class="mi">3</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">ScrollView</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">LazyHStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">ForEach</span><span class="p">(</span><span class="mi">0</span><span class="o">..&lt;</span><span class="mi">30</span><span class="p">)</span> <span class="p">{</span> <span class="n">item</span> <span class="k">in</span>
                    <span class="kt">ItemView</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">containerRelativeFrame</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="nv">count</span><span class="p">:</span> <span class="mi">7</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="n">scrollId</span> <span class="o">==</span> <span class="n">item</span> <span class="p">?</span> <span class="o">.</span><span class="nv">pink</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.3</span><span class="p">),</span> <span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">circle</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">id</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">scrollTargetLayout</span><span class="p">()</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">scrollPosition</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="err">$</span><span class="n">scrollId</span><span class="p">,</span> <span class="nv">anchor</span><span class="p">:</span> <span class="o">.</span><span class="n">center</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">scrollTargetBehavior</span><span class="p">(</span><span class="o">.</span><span class="n">viewAligned</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">90</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><img src="/assets/images/firstGif.gif" alt="firstGif" /></p>

<p>Here is our setup, now, we can trigger an event depending on the current scroll position.</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="o">.</span><span class="n">horizontal</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">LazyHStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/*...*/</span> <span class="p">}</span>
    <span class="o">.</span><span class="nf">scrollTargetLayout</span><span class="p">()</span>
    <span class="o">.</span><span class="nf">overlay</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">"Text"</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="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">scrollId</span><span class="p">)</span> <span class="p">?</span> <span class="o">.</span><span class="nv">green</span> <span class="p">:</span> <span class="o">.</span><span class="n">gray</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">capsule</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="o">-</span><span class="mi">90</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">scrollId</span><span class="p">,</span> <span class="p">{</span> <span class="n">oldValue</span><span class="p">,</span> <span class="n">newValue</span> <span class="k">in</span>
    <span class="c1">// trigger action if scrollId goes from trigger zone to non trigger zone</span>
    <span class="k">if</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">oldValue</span><span class="p">),</span>
       <span class="o">!</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">newValue</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// do something</span>
    <span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Obviously, added a label for users to see what is going on and used a different color when the scroll position comes to the “trigger zone.”</p>

<p><img src="/assets/images/secondGif.gif" alt="secondGif" /></p>

<p>It seems done, but let’s say we don’t want to trigger the action if we change our mind midway by scrolling back. In the current version, the action will be triggered no matter what.
We can track the scroll phase change to determine whether the user is interacting with the ScrollView or not. Fortunately, from iOS 18, there is a onScrollPhaseChange(_:) modifier.</p>

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

<span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isInteracting</span> <span class="o">=</span> <span class="kc">false</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kt">ScrollView</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/*...*/</span> <span class="p">}</span>
<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">scrollId</span><span class="p">,</span> <span class="p">{</span> <span class="n">oldValue</span><span class="p">,</span> <span class="n">newValue</span> <span class="k">in</span>
    <span class="k">if</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">oldValue</span><span class="p">),</span>
       <span class="o">!</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">newValue</span><span class="p">),</span>
       <span class="o">!</span><span class="n">isInteracting</span> <span class="p">{</span>
        <span class="c1">// do something</span>
        <span class="nf">print</span><span class="p">(</span><span class="s">"triggered"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">})</span>
<span class="o">.</span><span class="nf">onScrollPhaseChange</span><span class="p">({</span> <span class="n">_</span><span class="p">,</span> <span class="n">phase</span> <span class="k">in</span>
    <span class="n">isInteracting</span> <span class="o">=</span> <span class="n">phase</span> <span class="o">==</span> <span class="o">.</span><span class="n">interacting</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Finally, now we only trigger the action if the scrollId goes from the trigger zone to the non-trigger zone “naturally”.
Obviously, this approach is not suitable for all pull-to-trigger scenarios since it relies on the scroll position. You can use <code class="language-plaintext highlighter-rouge">onScrollGeometryChange(for:of:action:)</code> to get the current offset to handle more complex scenarios.
I made a <a href="&quot;https://github.com/alpaycli/ScrollableCalendarKit&quot;">Calendar View for SwiftUI using this approach</a> — take a look if you’re interested.
Thanks for reading!</p>]]></content><author><name>Alpay Calalli</name></author><summary type="html"><![CDATA[While I was working on making a performant calendar for my SwiftUI project, I was looking for a solution to load new dates as users scroll. Finally, decided to put only a single year’s data in a lazy container, and when you reach the end, pull to load more data, similar to “pull to refresh”. The solutions I found for pull-to-trigger action in SwiftUI were using drag gestures to track changes, but encountered bugs and weird behaviors while using ScrollView + drag gestures, so looked for another solution. With iOS 17, ScrollView has become more powerful with the addition of the missing APIs. The main modifier we’re going to use is scrollPosition(id:anchor:) to track the current position.]]></summary></entry></feed>