<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sheep_jh.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Fri, 27 Feb 2026 01:23:19 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sheep_jh.log</title>
            <url>https://velog.velcdn.com/images/sheep_jh/profile/8b670633-e7e7-487f-9d5b-a126b50bf4eb/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sheep_jh.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sheep_jh" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[HIG] iOS용으로 디자인하기]]></title>
            <link>https://velog.io/@sheep_jh/HIG-iOS%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%94%94%EC%9E%90%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/HIG-iOS%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%94%94%EC%9E%90%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Feb 2026 01:23:19 GMT</pubDate>
            <description><![CDATA[<p>지난글에서 iPad용으로 디자인하기를 다뤄봤는데, 우리와 가장 밀접하게 닿아있는 것은 iOS라 오늘은 <strong>iOS용으로 디자인하기</strong>를 살펴보겠다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/f54a86f9-f1dd-4c31-be66-55274bd2cc8a/image.png" alt=""></p>
<p>사람들은 어디에서나 이동 중에도 iPhone을 통해 소통하고, 게임을 플레이하고, 미디어를 보고, 작업을 수행하고, 개인 데이터를 추적한다.</p>
<p>iOS용 앱 또는 게임을 디자인할 때에는 iOS 경험을 차별화하는 기본적인 기기 특성 및 패턴을 먼저 이해해야 한다. 
이러한 <strong>특성과 패턴을 사용하여 디자인 결정</strong>을 내리는 것은 <strong>iPhone 사용자가 만족</strong>하는 앱이나 게임을 개발하는 데 도움이 될 수 있다.</p>
<blockquote>
<h3 id="5가지-특성">5가지 특성</h3>
</blockquote>
<p><strong>1. 디스플레이</strong> : iPhone에는 중형 크기의 고해상도 디스플레이가 탑재되어 있다.
<strong>2. 인체공학</strong> : 사람들은 일반적으로 iPhone을 한 손 또는 두 손으로 잡고 필요에 따라 가로와 세로로 화면 방향을 전환하며 iPhone과 상호작용한다. 사람들이 기기와 상호작용할 때 주시 거리는 대체로 <strong>약 30cm 또는 60cm</strong>를 넘지 않는다.
<strong>3. 입력</strong> : 사람들은 Multi-Touch <a href="https://developer.apple.com/kr/design/human-interface-guidelines/gestures">제스처</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/virtual-keyboards">가상 키보드</a> 및 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/siri">음성</a> 명령을 사용하여 이동 중에 동작을 수행하고 의미 있는 작업을 완료할 수 있다.
또한 사람들은 대체적으로 앱이 자신의 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/privacy">개인 데이터</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/gyro-and-accelerometer">자이로스코프 및 가속도계</a> 입력, <a href="https://developer.apple.com/design/human-interface-guidelines/spatial-interactions">공간 상호작용</a>에 참여하는 것을 원하기도 한다.
<strong>4. 앱 상호작용</strong> : 때때로 사람들은 <strong>1~2분만 사용</strong>하여 이벤트 또는 소셜 미디어 업데이트를 확인하거나, 데이터를 추적하거나, 메시지를 보낸다. 하지만 <strong>어떤 때는 1시간 이상</strong> 웹을 브라우징하거나, 게임을 플레이하거나, 미디어를 감상하기도 한다. 일반적으로 사람들은 동시에 여러 앱을 열어 놓으며, 여러 앱 간에 <strong>자주 전환</strong>한다.
<strong>5. 시스템 기능</strong> : iOS는 사람들이 친숙하고 일관적인 방식으로 시스템 및 앱과 상호작용할 수 있는 기능을 제공한다.</p>
<ul>
<li><a href="https://developer.apple.com/kr/design/human-interface-guidelines/widgets">위젯</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/home-screen-quick-actions">홈 화면 빠른 동작</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/searching">Spotlight</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/siri#Shortcuts-and-suggestions">단축어</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/activity-views">동작 보기</a></li>
</ul>
<blockquote>
<h3 id="모범-사례">모범 사례</h3>
</blockquote>
<p>탁월한 iPhone 경험을 제공하려면 사람들이 가장 중요하게 생각하는 <strong>플랫폼 및 기기의 기능이 통합</strong>되어야 한다. iOS와 잘 어울리도록 디자인하려면 다음과 같은 방법을 우선시하여 이러한 기능을 포함하면 좋다.</p>
<p><strong>1. 인지적 부하 최소화</strong> : 화면상의 제어기 수를 제한하고, <strong>최소한의 상호작용</strong>으로 추가 세부사항 및 동작을 찾을 수 있도록 하여 사람들이 주요 작업과 콘텐츠에 집중할 수 있도록 해야한다.</p>
<ul>
<li>(예) 음악 앱 (Apple Music) : 재생 목록에서는 곡 제목만 보여주다가, 특정 곡 옆의 ... 버튼을 눌러야 &#39;플레이리스트에 추가&#39;, &#39;공유&#39;, &#39;삭제&#39; 메뉴 등이 나타남.</li>
</ul>
<p><strong>2. 유연한 상태 대응</strong> : 기기 방향, 다크 모드, 다이나믹 타입과 같은 화면 모양 변경 사항을 매끄럽게 적용하여 사람들이 자신에게 가장 적합한 구성을 선택할 수 있도록 해야한다.</p>
<ul>
<li>(예) 메모 앱 : 사용자가 설정에서 글자 크기를 키우면, 메모 내용의 가독성을 위해 레이아웃이 자동으로 확장되며 줄바꿈이 조절됨.</li>
</ul>
<p><strong>3. 인체공학적 상호작용</strong> : 사람들이 일반적으로 기기를 잡는 방식과 부합하는 상호작용을 지원해야한다. 대부분의 사람들은 디스플레이의 중간 또는 하단 영역에 위치한 제어기가 사용하기 더 쉽고 편하다고 느낀다. 따라서 사람들이 쓸어넘기기 동작으로 뒤로 이동하거나, 목록 행에서 동작을 실행할 수 있도록 하는 것은 매우 중요하다.</p>
<ul>
<li>(예) Safari : 주소창을 화면 상단에서 하단으로 옮겨 엄지손가락이 쉽게 닿게 함. 또한, 화면 왼쪽 끝을 오른쪽으로 쓸어넘겨(Swipe) 뒤로 가기 지원함.</li>
</ul>
<p><strong>4. 플랫폼 기능 통합</strong> : 사람들로부터 권한을 받아, 데이터 입력을 요청하지 않으면서 경험을 향상하는 방식으로 플랫폼 기능을 통해 사용할 수 있는 정보를 통합해야한다. 결제를 수락하거나, 생체 인증을 통해 보안을 제공하거나, 기기의 위치를 사용하는 기능을 제공할 수 있다.</p>
<ul>
<li>(예) 배달 앱 : 주소를 직접 타이핑하게 하는 대신, GPS 위치 권한을 받아 &#39;현재 위치로 설정&#39; 기능을 제공. 결제 시 카드 번호 입력 대신 FaceID로 즉시 승인.</li>
</ul>
<blockquote>
<h3 id="마무리">마무리</h3>
</blockquote>
<p>예전에 유튜브 앱을 사용하면서 숏츠를 내리는데 오른쪽에 좋아요 부분이 너무 위에 있어서 한 손으로 누르기 어렵다는 생각을 했었는데, 그 이후 업데이트 버전에 좋아요 버튼이 밑으로 내려와서 신기했다.
아무래도 개발자가 <strong>인체공학적 상호작용</strong> 부분에 대해 고민하고 적용한 것이 아닌가 싶다.</p>
<p>또 나는,** 인지적 부하 최소화** 부분에서는 굉장히 신경쓰려고 노력하지만 <strong>유연한 상태 대응</strong> 부분에 있어서는 기기 크기, 다크 모드 대응 등 신경 쓸 case가 너무 늘어나다보니 잘 고려를 안했었는데 이 부분에 대해 좀 더 연구해서 잘 적용할 수 있게 노력해봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[HIG] iPadOS용으로 디자인하기]]></title>
            <link>https://velog.io/@sheep_jh/HIG-iPadOS%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%94%94%EC%9E%90%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/HIG-iPadOS%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%94%94%EC%9E%90%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 26 Feb 2026 01:57:25 GMT</pubDate>
            <description><![CDATA[<p>오늘은 HIG에서 <strong>iPadOS용으로 디자인하기</strong> 부분에 대해 살펴보겠다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/d13ee24c-e4fd-4ebe-8f66-af4d04a179c5/image.png" alt=""></p>
<p>사람들은 미디어를 감상하고, 게임을 플레이하고, 세밀한 생산성 작업을 수행하고, 창작 작업을 하도록 지원하는 iPad의 성능, 이동성, 유연성을 중요하게 생각한다.</p>
<p>iPad만의 특성과 패턴을 사용하여 디자인 결정을 내리는 것은 iPad 사용자가 만족하는 앱이나 게임을 개발하는 데 도움이 될 수 있다.</p>
<blockquote>
<h3 id="ipad의-5가지-특성-및-모범사례">iPad의 5가지 특성 및 모범사례</h3>
</blockquote>
<p>iPad용 앱/게임을 디자인할 때에는 iPadOS 경험을 차별화하는 5가지의 기본적인 기기 특성 및 패턴을 먼저 이해해야한다.</p>
<p>*<em>1. 디스플레이 *</em>: iPad에는 대형 크기의 고해상도 디스플레이가 탑재되어 있다.</p>
<ul>
<li>대형 디스플레이의 이점을 활용하여 모달 인터페이스와 <strong>전체 화면 전환을 최소화</strong>하고, 방해가 되는 곳이 아닌 <strong>쉽게 사용할 수 있는 곳에 화면상의 제어기를 배치</strong>하여 사람들이 관심을 갖는 콘텐츠를 부각시켜야한다.(예: 왼쪽 사이드바에는 메일 목록, 중앙에는 메일 본문을 크게 배치)</li>
</ul>
<p>*<em>2. 인체공학 *</em>: 아이패드는 손에 들거나, 책상에 눕히거나, 스탠드에 세우는 등 사용 환경이 매우 가변적이다.</p>
<ul>
<li>사용자와 기기 사이의 거리가 상황에 따라 변하므로(대략 91cm 이내), <strong>어떤 자세에서도 터치와 시인성이 확보</strong>되도록 UI 요소의 크기와 배치를 유연하게 설계해야 한다.</li>
</ul>
<p><strong>3. 입력</strong> : Multi-Touch <a href="https://developer.apple.com/kr/design/human-interface-guidelines/gestures">제스처</a> 및 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/virtual-keyboards">가상 키보드</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/apple-pencil-and-scribble">Apple Pencil</a>, 연결되 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/keyboards">키보드</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/pointing-devices">포인팅 장치</a>, <a href="https://developer.apple.com/kr/design/human-interface-guidelines/siri">음성</a> 등 다양한 입력 도구를 지원한다.</p>
<ul>
<li>사용자가 한 가지 방식만 고수하지 않고 <strong>여러 도구를 동시에 조합</strong>(예: 펜슬로 그리면서 손가락으로 메뉴 탭)하여 사용할 수 있도록 모든 입력 모드에 최적화된 상호작용을 제공해야 한다.</li>
</ul>
<p><strong>4. 앱 상호작용</strong> : 때때로 사람들은 iPad에서 몇 가지 간단한 동작을 수행한다. 하지만 어떤 때는 게임, 미디어, 콘텐츠 창작 또는 생산성 작업에 몇 시간 동안 몰두하기도 한다. 사람들은 자주 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/multitasking">여러 앱을 동시에</a> 열어 놓으며, 화면에서 한 번에 두 개 이상의 앱을 보고 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/drag-and-drop">드래그 앤 드롭</a>과 같은 앱 상호 기능을 사용한다.</p>
<p><strong>5. 시스템 기능</strong> : iPadOS는 사람들이 친숙하고 일관적인 방식으로 시스템 및 앱과 상호작용할 수 있는 여러 기능을 제공한다.</p>
<ul>
<li>기기 방향, 멀티태스킹 모드, 다크 모드, 다이나믹 타입과 같은 화면 모양 변경 사항을 매끄럽게 적용하고, macOS로 쉽게 전환되도록 하여 사람들이 자신에게 가장 적합한 구성을 선택할 수 있도록 해야한다.</li>
</ul>
<blockquote>
<h3 id="마무리">마무리</h3>
</blockquote>
<p>요즘 아이패드 관련 개발을 하고 있어서 HIG를 찾아봤다. 아이패드로 개발 시 어떤 특성을 이해하고 개발 해야하는지, 아이패드용 앱 설계시 뼈대가 되는 부분들을 살펴보았다.
특히 <strong>인체공학적인 부분</strong>에서 애플의 철학이자 <strong>Human Interface Guidline</strong>의 정의를 더 체감할 수 있었는데, 나는 사용자의 자세에 따른 아이패드 사용 환경에 대해 생각해본 적 없었다. 아마 이런 디테일한 부분들을 생각하며 설계한다면 사용자에게 더 편안하고 최적화된 경험을 줄 수 있지 않을까. 
앞으로의 개발은 사용자가 어떤식으로 경험하게 될 지 사용자 입장에서 체험 해보고 생각해 봐야겠다.</p>
<blockquote>
<h3 id="🍎참고">🍎참고</h3>
<p><a href="https://developer.apple.com/kr/design/human-interface-guidelines/designing-for-ipados">https://developer.apple.com/kr/design/human-interface-guidelines/designing-for-ipados</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] AVKit - VideoPlayer로 앱에 동영상 넣기
]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-AVKit-VideoPlayer%EB%A1%9C-%EC%95%B1%EC%97%90-%EB%8F%99%EC%98%81%EC%83%81-%EB%84%A3%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-AVKit-VideoPlayer%EB%A1%9C-%EC%95%B1%EC%97%90-%EB%8F%99%EC%98%81%EC%83%81-%EB%84%A3%EA%B8%B0</guid>
            <pubDate>Wed, 25 Feb 2026 05:43:23 GMT</pubDate>
            <description><![CDATA[<p>오늘은 앱에 동영상을 넣기 위해 <code>VideoPlayer</code>를 사용해 볼 것이다.
추가로 나는 온보딩 화면에 영상을 넣고 싶어서 그 부분도 구현해 볼 것이다.</p>
<p>애플 공식문서에 아주 간단한 예제가 있어서 바로 이해 가능할 것이다.</p>
<blockquote>
<h3 id="videoplayer-예제">VideoPlayer 예제</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI
import AVKit

struct VideoView: View {
    @State private var player: AVPlayer? //1
    @State private var isPlaying = false

    var body: some View {
        VStack {
            if let player {
                    //2
                VideoPlayer(player: player)
                    .frame(width: 320, height: 180, alignment: .center)

                //3
                Button {
                    isPlaying ? player.pause() : player.play()
                    isPlaying.toggle()
                    player.seek(to: .zero)
                } label: {
                    Image(systemName: isPlaying ? &quot;stop&quot; : &quot;play&quot;)
                        .padding()
                }
            }
        }
        //4
        .task {
            let url = URL(string: &quot;여기에 url 넣기&quot;)!
            player = AVPlayer(url: url)
        }
    }
}


#Preview {
    VideoView()
}
</code></pre>
<p>주요 포인트는 4가지다.</p>
<ol>
<li><p><code>AVPlayer</code>를 옵셔널로 뒀는데 <code>AVPlayer</code> 인스턴스를 선언 시점이 아닌 <code>task</code>에서 <strong>간접적으로 생성</strong>하는 방식을 사용한다. 
그 이유는 성능 문제나 기타 예기치 않은 부작용(Side effects)을 방지하는 데 도움이 되기 때문이다.</p>
</li>
<li><p>VideoPlayer 객체를 통해 비디오를 띄울 곳이다. frame 모디파이어를 통해 크기 조절이 가능하다.</p>
</li>
<li><p><code>pause()</code> 와 <code>play()</code> 는 각각 영상을 멈추고 재생하는 메서드고, <code>seek(to: .zero)</code> 이 친구는 영상을 0초 지점으로 되돌리는 메서드다.</p>
</li>
<li><p>비디오 플레이어 생성을 뒤로 미루기 위해 <code>task</code> 모디파이어를 사용한다. SwiftUI가 해당 뷰를 처음으로 나타낼 때, <strong>딱 한 번만 플레이어를 생성</strong>하도록 보장하기 위함이다. 
그리고 넣고 싶은 영상은 로컬 or 리모트 둘 다 url로 넣어주면 된다.</p>
</li>
</ol>
<blockquote>
<h3 id="온보딩에서-videoplayer">온보딩에서 VideoPlayer</h3>
</blockquote>
<p>앞에서 동영상을 뷰에 구현하는 것은 간단했다. 그러나 나는 온보딩 뷰에서 각각의 튜토리얼 동영상을 넣고싶고, 각각의 영상은 계속 반복되게 하고 싶다.</p>
<p>그러기 위해서는 3가지 객체를 알아야 한다.
바로 <strong>AVPlayerItem, AVQueuePlayer, AVPlayerLooper</strong> 이다.</p>
<p><strong>1. AVPlayerItem</strong> : 이 친구는 영상(<code>item</code>) 하나 하나를 담고 있는 객체다.
<strong>2. AVQueuePlayer</strong> : 이 친구는 여러 영상(<code>item</code>)을 재생하는 플레이어다.
<strong>3. AVPlayerLooper</strong> : 특정 영상(<code>item</code>)을 계속 <code>queue</code>에 추가해 반복 구현 해주는 객체다.</p>
<p>비유하자면</p>
<pre><code>AVPlayerItem → 재생할 노래파일 (MP3 파일)
 ↓
AVQueuePlayer → 플레이 리스트 (노래가 끝나면 다음 노래로 자동으로 넘어감)
 ↓
AVPlayerLooper → &quot;한 곡 반복&quot; 버튼 (재생 목록에 노래가 끝나면, 알아서 그 노래를 다시 재생 목록에 추가해줌)</code></pre><p>음악 앱을 생각해보면 된다.</p>
<blockquote>
<h3 id="온보딩-예제">온보딩 예제</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI
import AVKit

struct OnboardingView: View {
    @State private var currentPage = 0

    // 제미나이가 추천해준 url 샘플
    let videoUrls = [
        // 1. 토끼 애니메이션 (Big Buck Bunny)
        URL(string: &quot;https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4&quot;),

        // 2. 코끼리 꿈 애니메이션 (Elephants Dream)
        URL(string: &quot;https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4&quot;),

        // 3. 오픈 소스 영화 (For Bigger Blazes)
        URL(string: &quot;https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4&quot;)
    ].compactMap { $0 }

    var body: some View {
        TabView(selection: $currentPage) {
            ForEach(0..&lt;videoUrls.count, id: \.self) { index in
                OnboardingVideoCell(
                    videoURL: videoUrls[index],
                    isCurrentPage: currentPage == index
                )
                .tag(index)
            }
        }
        .tabViewStyle(.page(indexDisplayMode: .always))
        .ignoresSafeArea()
    }
}
</code></pre>
<p>먼저 온보딩 뷰에서는 제미나이한테 추천받은 url 샘플 3개와 함께 탭뷰로 3개의 페이지를 만들었다.</p>
<pre><code class="language-swift">struct OnboardingVideoCell: View {
    let videoURL: URL
    let isCurrentPage: Bool

    //1
    @State private var player: AVQueuePlayer?
    @State private var looper: AVPlayerLooper?

    var body: some View {
        ZStack {
            if let player = player {
                VideoPlayer(player: player)
                    .disabled(true) // 재생 컨트롤 숨김
                    .aspectRatio(contentMode: .fill)
            }
        }
        //2
        .task {
            setupPlayer()
        }
        //3
        .onChange(of: isCurrentPage) { _, newValue in
            newValue ? player?.play() : player?.pause()
        }
        //4
        .onDisappear {
            player?.pause() // 화면에서 완전히 사라질 때 정지
        }
    }

    //5
    private func setupPlayer() {
        // 1) 중복 생성 방지: 이미 있으면 또 만들지 않고 재생만 확인
        guard player == nil else {
            if isCurrentPage { player?.play() }
            return
        }

        // 2) 플레이어 아이템(동영상) -&gt; 큐플레이어(플레이리스트) -&gt; 플레이어 루퍼(무한재생 도우미)
        let item = AVPlayerItem(url: videoURL)
        let queuePlayer = AVQueuePlayer(playerItem: item)
        looper = AVPlayerLooper(player: queuePlayer, templateItem: item)

        // 3) 할당 및 재생
        self.player = queuePlayer
        if isCurrentPage { queuePlayer.play() }
    }
}</code></pre>
<ol>
<li><code>AVQueuePlayer</code>과 <code>AVPlayerLooper</code>를 옵셔널로 둬서 <code>.task</code>에서 간접적으로 생성 해준다.</li>
<li>뷰가 뜨자마자 플레이어를 설정(<code>setupPlayer</code>)한다. 만약 영상을 다 불러오기 전에 사용자가 화면을 넘겨버리면, 진행 중이던 로딩 작업을 알아서 취소해 리소스를 아낀다.</li>
<li>페이지 번호를 지켜보고 있다가, 현재 페이지가 활성화되면(<code>newValue == true</code>) 재생, 옆으로 넘기면 즉시 정지시킨다.</li>
<li>온보딩이 끝나고 메인 화면으로 이동했을 때, 메모리엔 남아있던 플레이어가 갑자기 소리를 내는 버그를 방지한다.</li>
<li>1) 이미 현재 페이지에 비디오가 있으면 중복으로 생성하는 걸 방지한다.
2) <code>AVPlayerItem</code> 로 동영상 하나를 담아두고 <code>AVQueuePlayer</code> 대기열에 추가시켜준다. 그 후 <code>AVPlayerLooper</code>로 플레이 리스트 안에 내가 지정한 영상 하나를 무한 반복시켜주는 것이다.
3) 영상을 할당해주고, isCurrentPage일 때만 틀어서 다른페이지와 소리가 겹치는 걸 방지한다.</li>
</ol>
<blockquote>
<h3 id="마무리">마무리</h3>
</blockquote>
<p>오늘은 VideoPlayer로 앱에 동영상 넣는 예제를 해보았는데, 기억에 남는거라면 <code>AVQueuePlayer</code> 부분이다. Queue(큐)는 CS에서 나오는 개념일텐데 아무래도 다들 CS를 중요하게 생각하는 이유가 이런부분에서 배경지식이 있다면 쉽게 원리를 이해할 수 있어서가 아닐까 싶다. CS를 개발과 구분짓지말고 엮어서 볼 수 있는 시각을 길러나가야겠다.</p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/avkit/videoplayer">https://developer.apple.com/documentation/avkit/videoplayer</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] animation, withAnimation, transition 비교하기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-animation-withAnimation-transition-%EB%B9%84%EA%B5%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-animation-withAnimation-transition-%EB%B9%84%EA%B5%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 24 Feb 2026 02:27:48 GMT</pubDate>
            <description><![CDATA[<p>개발하면서 애니메이션이 필요할 때마다 AI의 도움을 받아서 적용했었는데 이러다보니 항상 개발할 때마다 각 애니메이션 관련 문법을 언제, 어디에, 어떻게 써야 하는지 모른채 사용하는 난관에 빠지게 되었다.</p>
<p>그래서 오늘은 각 애니메이션 관련 문법이 어떤 역할을 하는지 비교하며 알아보려고 한다.</p>
<blockquote>
<h3 id="animation_value"><code>.animation(_:value:)</code></h3>
</blockquote>
<p>첫번째로 <code>.animation(_:value:)</code> 이 친구는 뷰 모디파이어로 붙여서 사용할 수 있다.</p>
<pre><code class="language-swift">Image(systemName: &quot;heart.fill&quot;)
    .scaleEffect(isLiked ? 1.5 : 1.0)
    .animation(.easeInOut, value: isLiked) </code></pre>
<p>이런식으로 사용하는데 파라미터로 animation과 value를 받는다.</p>
<ul>
<li><code>animation</code> :  어떤 애니메이션을 적용할지 고른다. (예: .easeInOut, .spring())</li>
<li><code>value</code> : 변화를 모니터링할 값이다.</li>
</ul>
<p>원리로는 <strong>value</strong>에 전달되는 값은 반드시 <a href="https://developer.apple.com/documentation/Swift/Equatable">Equatable</a> 프로토콜을 준수해야해서, 이전 렌더링 시점의 <strong>value</strong>와 현재의 <strong>value</strong>를 비교하여 값이 달라졌을 때를 감지하여 애니메이션을 구현해준다.</p>
<p><em>상태 변화에 따라 모든 뷰에 애니메이션을 적용하는 대신 *</em>특정 뷰에만 애니메이션을 적용**할 때 유용하다 !</p>
<hr>
<h3 id="예제">예제</h3>
<pre><code class="language-swift">import SwiftUI

struct SimpleAnimation: View {
    @State private var isLiked = false

    var body: some View {
        Image(systemName: &quot;heart.fill&quot;)
            .font(.largeTitle)
            .foregroundColor(.red)
            .scaleEffect(isLiked ? 1.5 : 1.0)
            .animation(.default, value: isLiked) //value가 바뀔때 애니메이션이 적용된다.
            .onTapGesture { isLiked.toggle() }
    }
}</code></pre>
<h3 id="영상">영상</h3>
 <img src="https://velog.velcdn.com/images/sheep_jh/post/3e898ddd-a784-479e-afe3-742aea9bc97a/image.gif" width="30%" />



<p>하트를 터치하여 isLiked의 상태가 바뀌었으므로 하트 크기가 커지고 작아지면서 애니메이션이 적용된다.</p>
<blockquote>
<h3 id="withanimation__"><code>withAnimation(_:_:)</code></h3>
</blockquote>
<p>두번째로 <code>withAnimation(_:_:)</code> 이 친구는 명령형으로 사용할 수 있다.</p>
<pre><code class="language-swift">Button(&quot;좋아요&quot;) {
    withAnimation(.easeInOut) {
        isLiked.toggle() 
    }
}</code></pre>
<p>이런식으로 코드를 감싸는 것만으로 애니메이션이 발동된다.
파라미터로 animation과 body를 받는다.</p>
<ul>
<li><code>animation</code> :  어떤 애니메이션을 적용할지 고른다. (예: .easeInOut, .spring())</li>
<li><code>body</code> : body는 클로저로, 애니메이션으로 만들고 싶은 <strong>&#39;상태 변화&#39;</strong>를 집어넣는 공간이다.</li>
</ul>
<p>원리로는 <code>withAnimation</code> 블록이 실행되면 SwiftUI는 즉시 <a href="https://developer.apple.com/documentation/swiftui/transaction">Transaction</a>이라는 객체를 하나 생성한다. 이 객체 안에는 우리가 설정한 애니메이션 정보(곡선, 시간 등)가 담겨 있다.</p>
<p><strong>동작 원리</strong></p>
<pre><code>1. withAnimation 호출 시 새로운 트랜잭션 컨텍스트가 열린다.

2. 블록 { } 내의 코드가 실행되며 상태(@State) 값이 변경된다.

3. 이 상태 변화로 인해 영향을 받는 모든 뷰 업데이트에 방금 만든 트랜잭션이 강제로 주입된다.

4. 결과적으로, 이 상태와 연결된 모든 뷰는 동일한 애니메이션 정보를 공유하며 한꺼번에 움직인다.</code></pre><p><em>어쨌든 <code>withAnimation</code> 은 *</em>상태변화에 해당하는 모든 뷰**를 애니메이션 하는데 유용하다.</p>
<hr>
<h3 id="예제-1">예제</h3>
<pre><code class="language-swift">struct SimpleWithAnimation: View {
    @State private var isChange = false

    var body: some View {
        VStack(spacing: 30) {
            HStack(spacing: 30) {
                Circle()
                    .frame(width: isChange ? 100 : 50)  // 크기 변경


                Circle()
                    .frame(width: 100)
                    .foregroundStyle(isChange ? .blue: .red) //색깔 변경
            }

            Button(&quot;모두 변경&quot;) {
                withAnimation { isChange.toggle() }
            }
        }
    }
}
</code></pre>
<h3 id="영상-1">영상</h3>
 <img src="https://velog.velcdn.com/images/sheep_jh/post/b3627069-f37f-481a-9230-fc5f897118c0/image.gif" width="30%" />


<p>&#39;모두 변경&#39; 버튼을 누르면 withAnimation안에 isChange가 토글되면서 상태가 변하고 isChange를 사용하고 있는 뷰들은 애니메이션 영향을 받는다. 그래서 크기 변경, 색깔 변경을 하는 각각의 원이 애니메이션이 적용된 모습이다.</p>
<blockquote>
<h3 id="transition_"><code>transition(_:)</code></h3>
</blockquote>
<p>세번째로 <code>transition(_:)</code> 이 친구는 뷰 모디파이어로 붙여서 사용할 수 있다.</p>
<pre><code class="language-swift">if isActive {
    MyView()
        .transition(.slide)
}
Button(&quot;Toggle&quot;) {
    withAnimation {
        isActive.toggle()
    }
}</code></pre>
<p>이런식으로 사용하는데 파라미터로 AnyTransition타입을 받는다.</p>
<ul>
<li><code>AnyTransition</code> :  뷰가 나타나고 사라지는 <strong>&#39;방법&#39;</strong>을 정의하는 객체다. (<code>.slide</code>, <code>.opacity</code> 같은 것들이 모두 이 타입에 해당)</li>
</ul>
<p><code>transition(_:)</code>을 뷰에 붙이면 이 뷰가 나타나거나 사라질 때 전환 효과가 적용되어 애니메이션 효과로 부드럽게 나타나거나 사라지는 것이다.</p>
<p><strong>동작 원리</strong></p>
<pre><code>1. 상태가 변경되어 뷰가 삭제되어야 하는 시점에, SwiftUI는 뷰에 transition이 있는지 확인한다.

2. transition이 있다면, 뷰를 즉시 삭제하지 않고 &#39;제거 중(Removal)&#39;이라는 특수한 상태로 유지한다.

3. 트랜잭션(withAnimation 등)에 설정된 시간 동안 뷰를 화면에 남겨두며, 설정된 경로(예: 점점 투명해짐, 아래로 이동)로 값을 보간(Interpolate)한다.

4. 애니메이션이 100% 완료되는 순간, 비로소 뷰 계층에서 완전히 들어낸다.</code></pre><p> 추가적으로, <strong>Asymmetric(비대칭)</strong>은 <code>insertion(등장)</code>, <code>removal(퇴장)</code>을 분리하여 아래와 같이 다르게 적용할 수 있다.</p>
<pre><code class="language-swift">.transition(.asymmetric(
    insertion: .move(edge: .leading), // 들어올 땐 왼쪽에서 슈욱
    removal: .opacity.combined(with: .scale) // 나갈 땐 투명해지며 작아짐
))</code></pre>
<p><em><code>transition</code>은 *</em>&#39;방법&#39;**만 정의하기 때문에 실제 애니메이션 실행하는 <code>withAnimation</code>이 있어야 적용된다. (transition 단독 사용 불가)</p>
<hr>
<h3 id="예제-2">예제</h3>
<pre><code class="language-swift">struct SimpleTransitionAnimation: View {
    @State private var show = false
    var body: some View {
        VStack {
            Button(&quot;Toggle&quot;) { withAnimation { show.toggle() } } //withAnimation이 존재해야 transition이 작동함

            if show {
                Text(&quot;나타났다!&quot;)
                    .transition(.slide) // 나타나고 사라질 때 옆으로 슥!
            }
        }
    }
}</code></pre>
<h3 id="영상-2">영상</h3>
 <img src="https://velog.velcdn.com/images/sheep_jh/post/617fa8f0-8f90-4fcf-bab1-a799d92ab465/image.gif" width="30%" />


<p>버튼을 누르면 텍스트가 옆에서 슥하고 나타난다. 그리고 다시 누르면 또 옆으로 슥하면서 사라진다. 아까도 말했던 것 처럼 등장과 퇴장이 둘 다 옆으로 슬라이드 되는 모습이 싫다면, <strong>Asymmetric(비대칭)</strong>을 이용하여 <code>insertion(등장)</code>, <code>removal(퇴장)</code>을 분리하면된다.</p>
<blockquote>
<h3 id="마무리">마무리</h3>
</blockquote>
<p><code>.animation(_:value:)</code> 은 <strong>value의 변화를 감지</strong>하여 이 뷰를 애니메이션 해라 !
<code>withAnimation(_:_:)</code> 은 이 <strong>안에 있는 상태 변화</strong>에 따라 상태 변화가 일어나는 모든 뷰에 애니메이션 해라 !
<code>transition(_:)</code>은 이 뷰가 나타나고 사라질 때 이 <strong>방법</strong>으로 전환해라 !</p>
<p>이런식으로 정리할 수 있었고, 애니메이션을 그냥 남용했던 나였지만 이제 각각의 의미를 알게되었고 필요에 따라 적재적소에 배치할 수 있도록 노력해야겠다.</p>
<blockquote>
<h3 id="🍎참고">🍎참고</h3>
<p><a href="https://developer.apple.com/documentation/swiftui/animations">https://developer.apple.com/documentation/swiftui/animations</a>
<a href="https://developer.apple.com/documentation/swiftui/view/animation(_:value:)">animation()</a>
<a href="https://developer.apple.com/documentation/swiftui/withanimation(_:_:)">withAnimation()</a>
<a href="https://developer.apple.com/documentation/swiftui/view/transition(_:)-5h5h0">transition()</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] 자연스러운 애니메이션을 위한 matchedGeometryEffect() 활용]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-%EC%9E%90%EC%97%B0%EC%8A%A4%EB%9F%AC%EC%9A%B4-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98%EC%9D%84-%EC%9C%84%ED%95%9C-matchedGeometryEffect-%ED%99%9C%EC%9A%A9</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-%EC%9E%90%EC%97%B0%EC%8A%A4%EB%9F%AC%EC%9A%B4-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98%EC%9D%84-%EC%9C%84%ED%95%9C-matchedGeometryEffect-%ED%99%9C%EC%9A%A9</guid>
            <pubDate>Mon, 23 Feb 2026 02:41:27 GMT</pubDate>
            <description><![CDATA[<p>애니메이션은 그 자체로 자연스러운 효과를 주지만, 단순히 나타나고 사라지는(Fade-in/out) 방식은 자칫 사용자의 시선 흐름을 툭 끊어버릴 수도 있다. </p>
<p>단순히 화면을 교체하는 게 아니라 화면의 형태를 유지하면서 자연스럽게 사용자의 시선을 가이드 한다면 사용자에게 <strong>&#39;연속적인 경험&#39;</strong>을 줄 수 있을 것이다.</p>
<p>그리고 그것을 <code>MatchedGeometryEffect</code>가 도와줄 것이다.</p>
<blockquote>
<h3 id="핵심-원리">핵심 원리</h3>
</blockquote>
<p>딱 두 가지만 알면 된다. </p>
<p><code>@Namespace</code> : 애니메이션이 일어날 공유 공간을 만든다. 같은 공간 안에 있는 이름표들끼리는 상대적인 위치 좌표를 계산할 수 있게 된다.</p>
<p><code>matchedGeometryEffect()</code> : 연결하고 싶은 두 뷰에 똑같은 id를 붙여준다. </p>
<pre><code class="language-swift">// 1. 공간을 만들고
@Namespace private var animationSpace 

// 2. 출발지와 도착지에 같은 이름표를 붙인다
.matchedGeometryEffect(id: &quot;uniqueID&quot;, in: animationSpace)</code></pre>
<p>이렇게 적용하면 같은 공간안에 있는 두 뷰가 애니메이션으로 전환되는 찰나에,  실제로 뷰가 이동되는 느낌을 주게 된다.</p>
<p>원리는 A뷰 -&gt;  B뷰로 전환될 때 <strong>두 뷰 사이의 거리와 크기 차이</strong>를 SwiftUI가 수학적으로 계산해서 그 찰나를 계속해서 메꿔주는 것이다.</p>
<p>즉, matchedGeometryEffect는 뷰를 실제로 옮기는 게 아니라, 두 뷰 사이의 거리와 크기 차이를 <a href="https://terms.naver.com/entry.naver?docId=3405107&amp;cid=47324&amp;categoryId=47324">보간(Interpolate)</a>해서 눈속임을 하는 기술이다.</p>
<blockquote>
<h3 id="예제-1--카테고리-바-이동">예제 1 : 카테고리 바 이동</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct CategoryMenu: View {
    @Namespace private var namespace // 1. 공간을 만들고
    @State private var selected = &quot;A&quot;
    let menus = [&quot;A&quot;, &quot;B&quot;, &quot;C&quot;]

    var body: some View {
        HStack(spacing: 30) {
            ForEach(menus, id: \.self) { menu in
                Text(menu)
                    .padding(10)
                    .background {
                        if selected == menu {
                            Capsule()
                                .fill(.blue.opacity(0.2))
                                // 2. 출발지와 도착지에 같은 이름표를 붙인다
                                .matchedGeometryEffect(id: &quot;background&quot;, in: namespace)
                        }
                    }
                    .onTapGesture {
                        withAnimation { selected = menu }
                    }
            }
        }
    }
}</code></pre>
<blockquote>
<h3 id="비교-영상">비교 영상</h3>
</blockquote>
<p>왼쪽이 <code>matchedGeometryEffect</code>를 사용하지 않은거고
오른쪽이 사용한 것이다.</p>
<div style="display: flex; gap: 10px;">
  <img src="https://velog.velcdn.com/images/sheep_jh/post/0278dc23-2bc7-4b19-b960-b00ffb978460/image.gif" width="50%" />
  <img src="https://velog.velcdn.com/images/sheep_jh/post/3ef3f0a9-f0dd-43e4-9193-f1b2e48791c8/image.gif" width="50%" />
</div>





<blockquote>
<h3 id="예제-2--썸네일-확대">예제 2 : 썸네일 확대</h3>
</blockquote>
<pre><code class="language-swift">struct HeroAnimation: View {
    @Namespace private var namespace // 1. 공간을 만들고
    @State private var isFull = false

    var body: some View {
        VStack {
            if !isFull {
                // 작은 썸네일
                RoundedRectangle(cornerRadius: 20)
                    // 2. 출발지와 도착지에 같은 이름표를 붙인다
                    .matchedGeometryEffect(id: &quot;card&quot;, in: namespace)
                    .frame(width: 100, height: 100)
            } else {
                // 전체 화면
                RoundedRectangle(cornerRadius: 0)
                    // 2. 출발지와 도착지에 같은 이름표를 붙인다
                    .matchedGeometryEffect(id: &quot;card&quot;, in: namespace)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
        .onTapGesture {
            withAnimation { isFull.toggle() }
        }
    }
}</code></pre>
<blockquote>
<h3 id="비교-영상-1">비교 영상</h3>
</blockquote>
<p>왼쪽이 <code>matchedGeometryEffect</code>를 사용하지 않은거고
오른쪽이 사용한 것이다.</p>
<div style="display: flex; gap: 10px;">
  <img src="https://velog.velcdn.com/images/sheep_jh/post/82ddebc6-23b9-4f94-9834-a49459f6bfca/image.gif" width="50%" />
  <img src="https://velog.velcdn.com/images/sheep_jh/post/b41436c8-9e0a-4721-adb0-e6f52fd39388/image.gif" width="50%" />
</div>



<blockquote>
<h3 id="마무리">마무리</h3>
</blockquote>
<p><code>matchedGeometryEffect</code> 를 이용한다면 뷰가 전환될 때 사용자에게 <strong>&#39;연속적인&#39;</strong> 경험을 아주 간편하게 줄 수 있다는 점을 깨달았다.</p>
<p>이런 경험 하나 하나가 축적된다면 사용자에게 편안함을 줄 수 있지 않을까 생각한다. </p>
<p>그리고 이름에는 <strong>&#39;Geometry&#39;</strong>가 붙고 이 뜻은 <strong>&#39;기하학&#39;</strong>이며, 공식 홈페이지에는 <strong>&#39;보간&#39;</strong>이라는 수학적 개념이 나오면서 왜 이런식으로 네이밍을 했는지 원리와 엮어서 보다보니 퍼즐 맞추는 것처럼 이해가 되었다. 앞으로도 이런 네이밍과 개념, 원리 등을 <strong>엮어서 볼 수 있는 시선</strong>을 가진다면 학습 능률이 올라갈 수 있겠다는 생각이 들었다.</p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/swiftui/view/matchedgeometryeffect(id:in:properties:anchor:issource:)">Apple 공식문서 - matchedGeometryEffect()</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[WWDC25] 디자인 기초 구축하기 ]]></title>
            <link>https://velog.io/@sheep_jh/WWDC25-%EB%94%94%EC%9E%90%EC%9D%B8-%EA%B8%B0%EC%B4%88-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/WWDC25-%EB%94%94%EC%9E%90%EC%9D%B8-%EA%B8%B0%EC%B4%88-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 22 Feb 2026 05:23:22 GMT</pubDate>
            <description><![CDATA[<p>오늘은 <code>WWDC25 - 디자인 기초 구축하기</code> 영상을 보고 <strong>뛰어난 앱</strong>을 제작하기 위한 <strong>뛰어난 디자인</strong>은 무엇인지 살펴볼 것이다.</p>
<blockquote>
<h3 id="개발자가-디자인을-알아야-하는-이유">개발자가 디자인을 알아야 하는 이유?</h3>
</blockquote>
<p>개인적인 생각으로
개발자가 직접 디자인을 하는 경우는 잘 없겠지만, 그것을 알아야 하는 이유라면 <strong>&#39;판단&#39;</strong> 때문이 아닐까? </p>
<p><strong>1인 개발자</strong>라면 디자인을 AI에게 맡기더라도, 어떤 것이 좋은 디자인인지 나쁜 디자인인지 <strong>&#39;판단&#39;</strong>하여 계속해서 프롬포트를 수정해나갈 수 있을것이다. 그리고 이는 사용자의 앱 이탈을 막는데 도움을 줄 것이다.</p>
<p><strong>현업 개발자</strong>라면 디자이너가 설계한 UI/UX를 보고 이것이 앱의 목적과 가치에 부합하는지 <strong>&#39;판단&#39;</strong>하고 사용자에게 더 나은 경험을 제공하기 위해 함께 논할 수 있을 것이다. 그리고 이는 협업의 질을 높이고 서비스의 완성도를 높일 수 있을 것이다.</p>
<p>그렇기에 개발자라도 디자인을 안다면 도움이 될것이다.</p>
<hr>
<p>그래서 WWDC 영상에서는 디자이너와 개발자가 뛰어난 앱을 제작하기 위해 앱의 <strong>4가지 핵심 영역</strong>(<code>구조</code>, <code>탐색</code>, <code>콘텐츠 구성</code>, <code>시각적 디자인</code>)에 초점을 맞추어 설명한다. 
이러한 요소를 다루면 앱의 <strong>목적, 가치, 상호작용</strong> 패턴에 대해 더 명확히 설명할 수 있을 것이다.</p>
<blockquote>
<h3 id="structure구조">Structure(구조)</h3>
<p>정보의 구성, 무슨 앱인지 그 기능을 정의하는 것</p>
</blockquote>
<h3 id="supports-discoverability-발견하기-쉽게-만들어져-있는-상태">Supports discoverability (발견하기 쉽게 만들어져 있는 상태)</h3>
<p>앱을 보았을 때 명확함이 느껴져야한다. 그러면 매력적인 경험이 되고 앱 이용이 수월해질 것이다. 이를 위해 지켜야하는 질문들은</p>
<h4 id="where-am-i-어디에-있는가">Where am I? 어디에 있는가?</h4>
<p>-&gt; 현재 위치가 어디인지 어떻게 왔는지 명확해야한다.</p>
<h4 id="what-can-i-do-무엇을-할-수-있는가">What can I do? 무엇을 할 수 있는가?</h4>
<p>-&gt; 추측할 필요 없이 동작이 명확하고 이해하기 쉬워야한다.</p>
<h4 id="where-can-i-go-어디로-갈-수-있는가">Where can I go? 어디로 갈 수 있는가?</h4>
<p>-&gt; 다음 단계가 명확하면 흐름이 유지되고 주저하거나 추측하는 일도 없다.</p>
<p>이러한 질문에 쉽게 답할 수 있다면 매력적이고 기초가 탄탄한 앱이다.</p>
<h4 id="예시--레코드-앱"><strong>예시 : 레코드 앱</strong></h4>
<p align="center"><img
src="https://velog.velcdn.com/images/sheep_jh/post/7e1da611-feb3-46d6-b02e-8e0793b81c8f/image.png" width="40%" height="40%"></p>

<p>언뜻 보기에는 잘 작동할 것이라고 넘겨짓게 된다. 그러나 자세히 보면</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/80955701-c85d-4b52-9e72-e927e3fe0551/image.png" width="40%" height="40%" align="center">

<p>1.
현재 위치가 확실해야 하는데 처음 보이는것은 메뉴다. 메뉴는 모호하고 예측이 어려워서 적절하지 않다. <strong>먼저 필요한 것은 맥락</strong>이다.</p>
<img src= "https://velog.velcdn.com/images/sheep_jh/post/1d155d00-c94e-4a5f-9c87-dd74193fcc90/image.png" width="40%" height="40%" align="center">

<ol start="2">
<li>다음으로 제목 브랜딩이 있는데 보기에는 좋지만 크게 도움이 되지 않는다. 넘겨 버리고 싶은 충동이 들 정도다.</li>
</ol>
<img src="https://velog.velcdn.com/images/sheep_jh/post/2b19225c-85ec-441f-a55a-229fd99c2126/image.png" width="40%" height="40%" align="center">

<ol start="3">
<li>때문에 앱의 추천 콘텐츠 조차 쉽사리 놓치고 마는 결과를 낳을 수 있다.</li>
</ol>
<img src="https://velog.velcdn.com/images/sheep_jh/post/b82d7e40-aa63-49c5-b48d-0b530850efc5/image.png" width="40%" height="40%" align="center">

<ol start="4">
<li>앨범들이 보이는데 둘러보기 외에는 가능 동작이 없어 현재 위치가 어디인지 해야 할 동작이 아직 불명확하다. 밑에 Records라는 탭바 이름이 있어서 겨우 현재 위치를 알 수 있지만 정보가 너무 늦게 제시된다.<br>



</li>
</ol>
<ul>
<li>결국 화면이 정확한 안내를 해주지 않아 정보를 찾아 해매야 했다.</li>
<li>구조가 불명확할 때 사용자는 머뭇거림, 혼란을 느끼고 포기하기도 한다.</li>
<li>앱 내용이 적었다면 열었을 때 간편하게 보였을 것이다.</li>
</ul>
<p>그리고 이것들이 바로 명확한 정보 구조화의 목표다. ⬇️⬇️⬇️</p>
<hr>
<h3 id="clear-information-architecture-명확한-정보-구조화">Clear information architecture (명확한 정보 구조화)</h3>
<p>사용자가 필요 시 원하는 것을 쉽게 찾도록 하는 과정이다.</p>
<h4 id="list-every-feature--앱의-모든-기능을-적기">List every feature : 앱의 모든 기능을 적기</h4>
<p>-&gt; 기능, 작업 흐름, 있으면 좋은 요소 등 다 적기 (이때, 아무것도 없애려고 하지 않고 모든 기능을 다 적는다)</p>
<h4 id="learn-from-people--다른-사람이-앱을-사용할때를-상상하기">Learn from people : 다른 사람이 앱을 사용할때를 상상하기</h4>
<p>-&gt; 언제 어디서 앱을 사용할 것인가? 앱이 사용자의 일상에 어떻게 들어맞는가? 사용자에게 실제로 도움이 되고 방해가 되는 것은 무엇인가? 이러한 질문의 답을 목록에 기록하기</p>
<h4 id="organize-insights--목록-완성되면-정리-시작">Organize insights : 목록 완성되면 정리 시작</h4>
<p>-&gt; 필요하지 않는 기능은 없애고, 불명확한 이름을 바꾸고, 자연스럽게 모을 수 있는 것들은 그룹화하기</p>
<blockquote>
<h3 id="navigation탐색">Navigation(탐색)</h3>
<p>사용자가 주도적으로 앱을 이용하도록 명확히 디자인하는 법</p>
</blockquote>
<p>탐색은 사용자가 앱에서 이동하는 방식이다.
그저 탭하는 동작 그 이상의 의미로 사용자가 방향성과 명확성을 느껴야 한다.</p>
<h3 id="tab-bar">Tab bar</h3>
<p>탭을 단순히 가져가야한다. 많을 수록 사용자가 복잡해지고 힘들어함</p>
<h4 id="what-deserves-a-tab-탭에-정말-필수적인-것은-무엇인가">What deserves a tab? 탭에 정말 필수적인 것은 무엇인가?</h4>
<h4 id="예제">예제</h4>
<img src="https://velog.velcdn.com/images/sheep_jh/post/e42b481a-9806-4310-9e99-2dad81951a62/image.png" width="40%" height="40%" align="center">

<ol>
<li>탭으로 만들 필요가 없는 것은 Crates탭이다. Crates는 레코드를 그룹화하는 화면에 불과하다. 둘 다 있을 필요가 없으므로 병합한다.</li>
</ol>
<img src="https://velog.velcdn.com/images/sheep_jh/post/519cffbd-3ae0-4854-82f9-f34a50630fed/image.png" width="40%" height="40%" align="center">

<ol start="2">
<li>Add탭이 있는데 기본 동작이라 탭 바에 위치하고 있다. 하지만 여기가 가장 좋은 위치인가? 의구심이 든다. 탭의 구성요소를 적절히 사용하는 시점이나 방법이 확실치 않으면 <a href="https://developer.apple.com/kr/design/human-interface-guidelines/tab-bars">HIG</a>를 살펴보면 된다. 
HIG에서는 탭은 동작이 아니라 탐색용이라고 확인된다.
그렇기에 Add를 많이 사용될 곳인 Records로 이동시킨다.</li>
</ol>
<img src="https://velog.velcdn.com/images/sheep_jh/post/c7f6aa6d-4bbc-4b16-9a50-23a3905b2196/image.png" width="40%" height="40%" align="center">

<ol start="3">
<li>각 탭의 내용을 가능하면 더 쉽게 예측할 수 있도록 해야 하므로, 탭에 아이콘 뿐만 아니라 이름을 붙이면 더 좋을 것이다. 무슨 용도인지 몰라 건너뛰지 않도록!</li>
</ol>
<ul>
<li>이제 탭바의 일부 항목이 이동했기 때문에 콘텐츠가 혼동을 줄 여지가 생겼다 그것을 Toolbar를 통해 해결해 보자</li>
</ul>
<hr>
<h3 id="toolbar">Toolbar</h3>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/0d55701c-537a-4504-89fa-713c44253b50/image.png" alt=""></p>
<p>before, after</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/ce0153d5-b374-4a0e-962b-f3aa3fc2e83d/image.png" width="50%" height="40%" align="center">

<ol>
<li>Toolbar를 통해 초반에 있었던 두 가지 문제인 현재 위치가 어딘가? 무엇을 할 수 있는가? 라는 문제가 모두 해결된다.
그 이유는 전과 달리 메뉴나 브랜딩 로고가 아닌 화면 이름이 포함된 제목과 도구들이 있기 때문이다. 이를 통해 화면의 콘텐츠를 짐작할 수 있으며 사용자가 탐색하고 스크롤할 때 방향성을 유지할 수 있다.</li>
</ol>
<img src="blob:https://velog.io/19161810-eece-4318-9180-3b61d4a873e3" width="40%" height="40%" align="center">

<ol start="2">
<li>이제 처음에 답하지 못했던 질문들에 답을 할 수 있을 것이다.</li>
</ol>
<ul>
<li><p>Where am I? (현재 위치가 어딘지)</p>
</li>
<li><blockquote>
<p>젤 위에 Records 타이틀과 젤 밑에 Tabbar를 통해 </p>
</blockquote>
</li>
<li><p>What can I do? (무엇을 할 수 있는지)</p>
</li>
<li><blockquote>
<p>Toolbar의 도구들을 통해 </p>
</blockquote>
</li>
<li><p>Where can I go? (어디로 갈 수 있는지) </p>
</li>
<li><blockquote>
<p>Tabbar를 통해 </p>
</blockquote>
</li>
</ul>
<p>사용자가 처음부터 명확한 경험을 할 수 있게 준비된 것이다.</p>
<blockquote>
<h3 id="content콘텐츠-구성">Content(콘텐츠 구성)</h3>
<p>화면의 실제 항목들</p>
</blockquote>
<h3 id="organization">Organization</h3>
<p>앱의 콘텐츠는 가장 중요한 것이나 먼저 보기를 원하는 것으로 안내하도록 배치해야 한다.</p>
<h4 id="예제-1">예제</h4>
<img src="https://velog.velcdn.com/images/sheep_jh/post/5dca1495-96da-4af7-bbfa-dbe453cd4e27/image.png" width="60%" height="40%" align="center">

<p>현재 콘텐츠는 Groups, Records로 나뉘어져서 지저분한 느낌을 주고 있다. 두 섹션을 나눌 것이다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/5968e5d9-ae77-4ab8-b4cb-659720df67d2/image.png" width="40%" height="40%" align="center">

<p>Record Groups 부분에 2가지 콘텐츠만 보여주고 나머지는 더보기를 제공해주고 있다. 이 개념을** Progressive disclosure(단계적 공개)**라고 한다.
사용자가 시작하는데 필요한 정도의 내용만 먼저 보여주고 이후에는 상호작용을 통해 추가 정보를 확인하도록 하는 방식이다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/1472a973-1193-4de3-8a92-e25507772667/image.png" width="40%" height="40%" align="center">

<p>추가 정보가 열릴 때 콘텐츠가 동일하게 정렬되어있으면 좋다. 이전 화면과 연결되어 있으면서 확장되는 느낌이 들도록. 
탐색에서 설명했듯이 모든 화면은 방향을 제시해야 하는데 Toolbar를 보면 뒤로가기 버튼과 도구가 있어 어디서 왔는지, 무엇을 할 수 있는지, 어디로 가야하는지 알기 쉽다.</p>
<hr>
<h3 id="layout">Layout</h3>
<ul>
<li>앞에서 단계적 공개는 올바른 수정 방향이었지만 격자 레이아웃은 최선이 아닌거 같다. 두 가지 항목만 담기에는 공간을 너무 많이 차지하기 때문. 또한 긴 텍스트를 표시하지 못해 콘텐츠가 불분명해진다.</li>
</ul>
<img src="https://velog.velcdn.com/images/sheep_jh/post/b85a005e-7ee1-4db1-9c96-d11acebb59ac/image.png" width="40%" height="40%" align="center">

<p>그래서 격자 레이아웃에서 리스트로 변환 해주면, 구조화된 정보 표시에 유연하고 빠르게 훑어볼 수 있다는 점에서 훨씬 효과적이다. 또한 이미지보다 수직 공간을 덜 차지하므로 더 많은 항목을 표시할 수 있다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/7dd935f5-f8c6-43fc-ba27-f0cf2e2f40b8/image.png" width="40%" height="40%" align="center">

<p>All Records에서는 모든 것을 미리 보여줘 사용자가 둘러보는 것을 목표로한다. 그러나 선택의 폭이 넓어지면 사용자가 콘텐츠를 탐색하는 대신 부담감을 느껴 앱을 중단할까 걱정되기도 한다. 그래서 대량 콘텐츠 표시를 구상하기 전에 콘텐츠 정리가 필요하다.</p>
<ul>
<li>앱을 정리할 때 사용하는 3가지 테마가 있다.</li>
<li><em>1. Time : 시간별 콘텐츠 분류*</em></li>
<li><blockquote>
<p>이 방법은 최근 파일을 찾을 때나 스트리밍 중인 프로그램을 이어서 시청할 때 유용하다. 또한 계절과 현재 이벤트를 고려한 그룹화에도 유용하다.</p>
</blockquote>
</li>
<li><em>2. Progress : 진행 상황에 따라 그룹화*</em></li>
<li><blockquote>
<p>이메일 초안, 진행 중 수업 등 중단한 곳에서 다시 진행할 수 있다. 앱이 실생활에 반응한다는 느낌을 주기에 좋은 방법인데 누구든 한 번에 모든 것을 끝내는 경우는 드물다.</p>
</blockquote>
</li>
<li><em>3. Patterns : 패턴별로 그룹화*</em></li>
<li><blockquote>
<p>관계, 관련 제품처럼 한데 모을 수 있는 항목을 드러내는 방식이다. 패턴을 드러내는 방식에서는 짧은 둘러보기가 긴 탐색이 되는데 사용자가 미처 생각지 못했던 연결 고리를 보여주기 때문이다.</p>
</blockquote>
</li>
</ul>
<p>이러한 그룹화로 선택 과부하를 줄이고 선제적 앱을 만들 수 있다.</p>
<h4 id="예제-2">예제</h4>
<img src="https://velog.velcdn.com/images/sheep_jh/post/50341097-5d00-4954-93ed-37905720308d/image.png" width="50%" height="40%" align="center">

<ol>
<li>Time: 컬렉션을 나눴고, 여기서는 시간 기준으로 여름에 맞는 레코드를 표시한다.</li>
</ol>
<img src="https://velog.velcdn.com/images/sheep_jh/post/f27cab6a-2e98-4cd2-b535-b3567df18361/image.png" width="50%" height="40%" align="center">
2. Progress: 진행 상황 기준으로는 전집이나 음반 목록을 표시한다.


<img src="https://velog.velcdn.com/images/sheep_jh/post/96b51287-2520-4b89-9fb4-e6a69011c1c4/image.png" width="50%" height="40%" align="center">
3. Pattern: 패턴 기준으로는 스타일이나 장르로 구분한다.
<br><br>

<ul>
<li>콘텐츠를 신중히 구성하고 친숙한 플랫폼 요소를 배치하면 사용자가 중요한 것을 쉽게 찾을 수 있기 때문에 직관적이면서 계속 다시 사용하고 싶은 공간이 완성된다.</li>
</ul>
<blockquote>
<h3 id="4-시각적-디자인">4. 시각적 디자인</h3>
<p>알맞은 스타일링으로 앱의 개성과 분위기를 살리고 사용성을 높이는 법</p>
</blockquote>
<p>시각 디자인은 앱의 개성 전달과 사용자의 감정 형성을 한다.</p>
<h3 id="supports-function">Supports function</h3>
<p>계층, 타이포그래피, 이미지, 색상을 세심하게 사용하고 기능을 지원하는 역할을 한다. 앱의 시각 디자인을 발전시키려면 효과적인 부분과 개선이 필요한 부분을 파악해야한다. 활자, 색상 및 이미지가 조화를 이루는 방식에 집중해야한다.</p>
<h4 id="예제-3">예제</h4>
<img src="https://velog.velcdn.com/images/sheep_jh/post/6c2de7c2-3891-4c6a-a56a-51a92d043aee/image.png" width="40%" height="40%" align="center">

<p>앱을 슬쩍보면 격자 부분 콘텐츠가 제일 눈에 띄는데, 시각적으로 무겁고 다채롭기 때문이다. 콘텐츠의 절반이 빠졌고 공간감도 떨어진다. 여기서 부족한건 &#39;<strong>시각적 계층&#39;</strong>이다. 관건은 화면에서 시선을 안내하여 중요도 순으로 각종 디자인 요소를 인지하도록 하는 것이다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/5f9dc7d9-7957-42bb-a9ca-64dcd54109df/image.png" width="40%" height="40%" align="center">

<p>시각 디자인을 개선하기 위해 이 추천 카드를 시각 앵커로 바꿔준다. 가장 중요한 것의 크기를 키우거나 대비를 높여주는 것이다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/d9006100-b30c-4008-8a9f-2d11611965d6/image.png" width="40%" height="40%" align="center">

<p>이렇게 하면 자연스럽게 먼저 시선이 간다. 그리고 시각적으로 제 역할을 다한다. 하지만 텍스트가 길어지거나 언어가 변경되거나 사용자가 큰 텍스트를 사용한다면 지속되기 어려울 것이다. 활자 측면에서 더 유연하게 대응해야 한다.</p>
<p>이럴 때 유용한 것이 <strong>시스템 텍스트 스타일</strong>이다.</p>
<hr>
<h3 id="use-text-styles">Use text styles</h3>
<p>이를 통해 다양한 화면 조건에서도 명확한 계층과 강력한 가독성을 쉽게 구현할 수 있다. 제목부터 자막까지 모든 것을 일관되게 스타일링 할 수 있다.</p>
<h4 id="예제-4">예제</h4>
<img src="blob:https://velog.io/3b720324-6887-4633-be05-919d0265d419" width="50%" height="40%" align="center">

<p>앨범 커버를 배경에 놓아주면 텍스트에 지속적인 공간이 확보되며, 세 가지 텍스트 스타일로 크기 및 대비 변화를 주어 시선을 유도할 수 있다. 텍스트 스타일은 Dynamic Type도 지원하여 사용자가 자신에게 편안한 텍스트 크기를 선택할 수 있다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/7282397d-9441-4f43-acbd-2ffb4f1fb78a/image.png" width="50%" height="40%" align="center">

<p>텍스트가 이미지 위에 겹쳐지면 가독성이 문제가 될 수 있으므로 이러한 경우에는 <strong>명확성</strong>을 우선시해야 한다. </p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/21c61a36-872a-4b9d-8757-dba4c12d28f7/image.png" width="50%" height="40%" align="center">


<p>간단한 해결법 중 하나는 텍스트 뒤에 <strong>그라데이션, 블러</strong> 등 은은한 배경을 추가하는 것이다.</p>
<hr>
<h3 id="use-images-and-color-intentionally">Use images and color intentionally</h3>
<p>이미지와 색상이 앱의 개성을 전달하는데 도움이 된다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/28d42071-75d9-4ae8-9a49-53bb6a42b861/image.png" width="40%" height="40%" align="center">

<p>목록 부분을 보면 너무 단순화한 나머지 구성요소 사이에서 길을 잃은 감이 있다. </p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/f0cff579-5999-424a-bd09-2ece02ddb6df/image.png" width="40%" height="40%" align="center">

<p>목록을 쉽게 볼 수 있도록 각 그룹에 이미지를 넣는다. 하지만 일부 이미지가 너무 튄다. 모두 색상과 스타일이 크게 다르다. 정말 필요한 것은 <strong>일관된 시각 스타일</strong>이다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/dea6c0a5-a6df-413e-8168-4b6f6852f417/image.png" width="70%" height="70%" align="center">

<p><strong>Use a cohesive style (일관된 스타일 적용)</strong>
색상 팔레트를 선택하고 몇 가지 간단한 규칙을 정한다. 네 가지 색상을 선택하고 복고풍 모양을 조합한다. 제목이 표시되는 그룹의 경우 더 굵고 넓은 서체를 사용한다.</p>
<img src="https://velog.velcdn.com/images/sheep_jh/post/4a593c45-1984-4f4e-8d65-5b8919544f47/image.png" width="40%" height="40%" align="center">
그 결과 앱의 개성이 돋보이고 앱을 확장할 때도 일관성 유지가 가능할 거 같다. 

<p>추가적으로</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/698d62ea-14bb-41aa-b71e-aa1879a63a9d/image.png" alt=""></p>
<p>이미 색상이 있지만 팔레트에서 고른 것이 아니라 label이나 secondarySystemBackground같은 이름이다. 이것을 <strong>Semantic colors(의미 색상)</strong>이라 하며 동적인 색상이다. 이러한 속성은 라이트 및 다크 모드에 따라 자동으로 변경된다.</p>
<blockquote>
<h3 id="마무리">마무리</h3>
</blockquote>
<p>디자인은 100% 라는 것이 없으며 정해진 답이 없다. 오늘 디자인 기초 사항을 살펴 보았는데 <strong>&#39;디자인&#39;</strong>이라는 단어가 재해석된 느낌이다.</p>
<p>나는 원래 <strong>&#39;디자인&#39;</strong>이라는 말을 들으면 무언가를 예쁘게 꾸미거나, 시각적으로 뛰어나게 만드는 등 <strong>&#39;미술&#39;</strong>에 가깝다고 생각해왔다.</p>
<p>그러나 이 영상을 정리하며 디자인은 사용자가 목적에 도달하기 위해 편안함을 <strong>&#39;설계&#39;</strong>하는 것이라고 개인적으로 재해석 해볼 수 있었다.</p>
<p>더 나은 개발자가 되기 위해서는 개발뿐만 아니라 앱을 디자인 하는 과정에서 어떤 것이 사용자를 위한 설계인지 판단 할 수 있어야 할 것이고, 이를 위해 디자인에 대해 공부할 필요가 있을 것이다.</p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/kr/videos/play/wwdc2025/359/">https://developer.apple.com/kr/videos/play/wwdc2025/359/</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] AVAudioRecorder로 녹음 구현하기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-AVAudioRecorder%EB%A1%9C-%EB%85%B9%EC%9D%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-AVAudioRecorder%EB%A1%9C-%EB%85%B9%EC%9D%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 17 Feb 2026 11:28:13 GMT</pubDate>
            <description><![CDATA[<p>앱에서 녹음 기능을 구현하기 위해 <code>AVAudioRecorder</code> 에 대해 알아보려고 한다.</p>
<blockquote>
<h3 id="avaudiorecorder">AVAudioRecorder</h3>
</blockquote>
<p><strong>AVAudioRecorder</strong>는 애플의 AVFoundation 프레임워크에 포함된 클래스로, 기기의 마이크를 통해 들어오는 오디오 신호를 실제 파일로 저장할 때 사용하는 <strong>&quot;녹음기 객체&quot;</strong>다.</p>
<hr>
<p><strong>*주요 역할</strong></p>
<p>*<em>1. 연결 *</em>: 마이크와 앱 사이의 통로를 열어 소리를 받아온다.</p>
<p>*<em>2. 변환 *</em>: 아날로그 소리 신호를 우리가 지정한 디지털 포맷(AAC, PCM 등)으로 변환한다.</p>
<p>*<em>3. 저장 *</em>: 변환된 데이터를 기기 내부(Document 폴더 등)에 파일 형태로 기록한다.</p>
<br>
녹음을 하기 위해서는 우선 권한 설정을 해주어야 한다.

<blockquote>
<h3 id="권한설정">권한설정</h3>
</blockquote>
<p>Info.plist 에서 Privacy - Microphone Usage Description 권한을 추가해준다.
<img src="https://velog.velcdn.com/images/sheep_jh/post/5ebbe323-6d30-4ac4-a003-88ac7a691377/image.png" alt=""></p>
<blockquote>
<h3 id="audiomanager">AudioManager</h3>
</blockquote>
<pre><code class="language-swift">import AVFoundation

@Observable
class AudioManager {
    var audioRecorder: AVAudioRecorder? // 녹음을 담당하는 객체
    var audioPlayer: AVAudioPlayer?     // 재생을 담당하는 객체
    var isRecording = false             // 현재 녹음 중인지 상태값
    var recordedFileURL: URL?           // 녹음된 파일이 저장된 경로

    // [1] 마이크 권한 요청
    func requestPermission() {
        AVAudioApplication.requestRecordPermission { granted in
            print(&quot;마이크 권한 허용 여부: \(granted)&quot;)
        }
    }

    // [2] 녹음 시작
    func startRecording() {
        let session = AVAudioSession.sharedInstance()

        do {
            // 녹음 세션 설정: 재생(play)과 녹음(record)을 동시에 지원하며, 소리를 스피커로 출력하도록 설정
            try session.setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker)
            try session.setActive(true)

            // 저장 경로 설정: 앱 내 &#39;Documents&#39; 폴더에 my_recording.m4a 이름으로 저장
            let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            recordedFileURL = documentPath.appendingPathComponent(&quot;my_recording.m4a&quot;)

            // 녹음 설정 (포맷, 샘플 레이트, 채널 등)
            let settings: [String: Any] = [
                AVFormatIDKey: Int(kAudioFormatMPEG4AAC), // AAC 포맷
                AVSampleRateKey: 44100,                   // 44.1kHz 샘플링
                AVNumberOfChannelsKey: 1,                 // 모노 녹음
                AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
            ]

            // Recorder 초기화 및 녹음 시작
            audioRecorder = try AVAudioRecorder(url: recordedFileURL!, settings: settings)
            audioRecorder?.prepareToRecord()
            audioRecorder?.record()
            isRecording = true
        } catch {
            print(&quot;녹음 시작 실패: \(error.localizedDescription)&quot;)
        }
    }

    // [3] 녹음 중단
    func stopRecording() {
        audioRecorder?.stop()
        isRecording = false
    }

    // [4] 녹음 파일 재생
    func startPlaying() {
        guard let url = recordedFileURL else { return }

        do {
            // 재생 시에는 세션을 재생(playback) 전용 모드로 변경하여 음질 최적화
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
            audioPlayer = try AVAudioPlayer(contentsOf: url)
            audioPlayer?.play()
        } catch {
            print(&quot;재생 실패: \(error.localizedDescription)&quot;)
        }
    }
}</code></pre>
<p>핵심은 녹음 시작 메서드 <code>func startRecording()</code> 인데 녹음이 어떤 과정을 거치는지 보겠다.</p>
<p><strong>1. 녹음 세션 설정</strong> : 재생(play)과 녹음(record)을 동시에 지원하며, 소리를 스피커로 출력하도록 세션을 설정해준다.</p>
<p><strong>2. 저장 경로 설정</strong> : 앱 내 &#39;Documents&#39; 폴더에 my_recording.m4a 이름으로 녹음본을 저장해준다.</p>
<p><strong>3. 세밀한 녹음 설정</strong> : 포맷, 샘플 레이트, 채널 등을 설정하는데 각각 이렇게 된다.</p>
<ul>
<li>포맷 : 용량이 작은 m4a(AAC)로 할지, 무손실인 wav(PCM)로 할지 결정</li>
<li>샘플 레이트 : $44100Hz$(CD 음질) 등 소리의 해상도 설정</li>
<li>채널 : 모노(1)로 할지 스테레오(2)로 할지 선택</li>
</ul>
<p><strong>4. Recorder 초기화 및 녹음 시작</strong></p>
<blockquote>
<h3 id="view">View</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct RecordingView: View {
    // AudioManager 인스턴스 생성
    @State private var audioManager = AudioManager()

    var body: some View {
        VStack(spacing: 50) {
            // 현재 상태 표시
            Text(audioManager.isRecording ? &quot;녹음 중...&quot; : &quot;대기 중&quot;)
                .font(.title)
                .bold()
                .foregroundColor(audioManager.isRecording ? .red : .primary)

            HStack(spacing: 40) {
                // 녹음 및 중지 버튼
                Button(action: {
                    if audioManager.isRecording {
                        audioManager.stopRecording()
                    } else {
                        audioManager.startRecording()
                    }
                }) {
                    VStack {
                        Image(systemName: audioManager.isRecording ? &quot;stop.circle.fill&quot; : &quot;circle.fill&quot;)
                            .resizable()
                            .frame(width: 80, height: 80)
                            .foregroundColor(.red)
                        Text(audioManager.isRecording ? &quot;중지&quot; : &quot;녹음&quot;)
                    }
                }

                // 재생 버튼
                Button(action: {
                    audioManager.startPlaying()
                }) {
                    VStack {
                        Image(systemName: &quot;play.circle.fill&quot;)
                            .resizable()
                            .frame(width: 80, height: 80)
                            // 파일이 없거나 녹음 중일 때는 비활성화 색상(gray) 처리
                            .foregroundColor(audioManager.recordedFileURL == nil || audioManager.isRecording ? .gray : .blue)
                        Text(&quot;재생&quot;)
                    }
                }
                // 녹음 중이거나 파일이 없으면 버튼 비활성화
                .disabled(audioManager.recordedFileURL == nil || audioManager.isRecording)
            }
        }
        .padding()
        // 뷰가 나타날 때 마이크 권한 확인
        .onAppear {
            audioManager.requestPermission()
        }
    }
}

#Preview {
    RecordingView()
}
</code></pre>
<p>간단한 녹음 예제를 구현해보았다.</p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/avfaudio/avaudiorecorder">https://developer.apple.com/documentation/avfaudio/avaudiorecorder</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] SFSpeechRecognizer와 함께 실시간으로 STT 출력하기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-SFSpeechRecognizer%EC%99%80-%ED%95%A8%EA%BB%98-%EC%8B%A4%EC%8B%9C%EA%B0%84%EC%9C%BC%EB%A1%9C-STT-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-SFSpeechRecognizer%EC%99%80-%ED%95%A8%EA%BB%98-%EC%8B%A4%EC%8B%9C%EA%B0%84%EC%9C%BC%EB%A1%9C-STT-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 17 Feb 2026 10:37:38 GMT</pubDate>
            <description><![CDATA[<p>오늘은 <code>STT(Speech-To-Text)</code>를 구현해보려고한다. 내 목소리가 실시간으로 텍스트로 변하는 예제를 구현해보겠다.</p>
<p>그전에 먼저, <strong>SFSpeechRecognizer</strong>에 관해 알아야 한다.</p>
<blockquote>
<h3 id="sfspeechrecognizer">SFSpeechRecognizer</h3>
</blockquote>
<p><code>SFSpeechRecognizer</code>는 iOS에서 음성 인식 프로세스를 관리하는 핵심 객체다. 단순히 소리를 듣는 것을 넘어, 언어 설정, 권한 확인, 실제 변환 작업(Task)까지 모두 이 객체를 통해 이루어진다.</p>
<hr>
<p><strong>* 주요 역할</strong></p>
<p><strong>1. 음성 인식의 엔진</strong>: 마이크를 통한 실시간 음성이나 오디오 파일의 음성을 텍스트로 변환한다.</p>
<p><strong>2. 다양한 언어 지원</strong>: Locale 설정을 통해 한국어(ko-KR)를 포함한 다양한 언어를 지원한다.</p>
<p><strong>3. On-Device &amp; Server</strong>: 기기 자체에서 처리하거나 애플 서버를 거쳐 높은 정확도로 처리한다.</p>
<hr>
<p><strong>* 음성 인식 구현 6단계</strong>
공식 문서에서 권장하는 표준 프로세스는 다음과 같다.</p>
<p><strong>1. 권한 요청</strong> : <code>SFSpeechRecognizer.requestAuthorization</code>을 호출해 사용자 승인을 받는다.</p>
<p><strong>2. 객체 생성</strong> : <code>SFSpeechRecognizer(locale:)</code>를 통해 인스턴스를 만듭니다.</p>
<p><strong>3. 가용성 확인</strong> : <code>isAvailable</code> 프로퍼티로 현재 서비스 이용 가능 여부를 체크한다. (인터넷 연결 환경 등에 따라 달라질 수 있음)</p>
<p><strong>4. 오디오 준비</strong> : 마이크 입력(실시간) 또는 오디오 파일(기존 녹음)을 준비한다.</p>
<p><strong>5. Request 객체 생성</strong> : 상황에 맞는 리퀘스트 객체를 생성한다.</p>
<ul>
<li><p>파일 처리 : <code>SFSpeechURLRecognitionRequest</code></p>
</li>
<li><p>실시간 스트림 : <code>SFSpeechAudioBufferRecognitionRequest</code></p>
</li>
</ul>
<p><strong>6. 인식 시작</strong> : <code>recognitionTask(with:resultHandler:)</code> 메서드를 호출하여 변환을 시작한다.</p>
<blockquote>
<h3 id="4가지-주요-객체">4가지 주요 객체</h3>
</blockquote>
<p>코드 실습 전, 미리 보면 좋을 4가지 객체를 알아보겠다.</p>
<p><strong>1. AVAudioEngine (음성 엔진)</strong></p>
<ul>
<li>역할 : 마이크로부터 들어오는 <strong>실제 물리적인 소리(오디오 데이터)</strong>를 수집하고 전달하는 통로다.</li>
<li>주요 기능 : <code>inputNode</code>를 통해 마이크에 접근하고, <code>installTap</code>으로 소리 데이터를 낚아챈다.</li>
</ul>
<p><strong>2. SFSpeechRecognizer (음성 인식기)</strong></p>
<ul>
<li>역할 : 음성 인식 서비스의 중앙 제어 장치다.</li>
<li>주요 기능 : 생성할 때 <strong>언어(Locale)</strong>를 지정하며, 현재 음성 인식이 가능한 상태인지(<code>isAvailable</code>) 관리한다.</li>
</ul>
<p><strong>3. SFSpeechAudioBufferRecognitionRequest (인식 요청 객체)</strong></p>
<ul>
<li>역할 : 오디오 엔진에서 가져온 음성 데이터(<strong>Buffer</strong>)를 담아 인식기에 전달하는 바구니다.</li>
<li>주요 기능 : <code>shouldReportPartialResults</code> 설정을 통해 문장이 끝나기 전에도 실시간으로 텍스트를 보여줄지 결정한다.</li>
</ul>
<p><strong>4. SFSpeechRecognitionTask (인식 작업 상태)</strong></p>
<ul>
<li>역할 : 실제 음성 인식 프로세스의 실행 상태를 제어한다.</li>
<li>주요 기능 : 인식이 잘 되고 있는지 모니터링하고, 작업을 취소(<code>cancel</code>)하거나 종료(<code>finish</code>)하는 핸들 역할을 한다.</li>
</ul>
<br>
코드 예제와 함께 보기전에, 권한 설정부터 하겠다.

<blockquote>
<h3 id="권한설정">권한설정</h3>
</blockquote>
<p>info.plst에서 아래 권한을 추가해줘야 크래시가 나지 않는다.</p>
<ul>
<li>Privacy - Speech Recognition Usage Description</li>
<li>Privacy - Microphone Usage Description</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/ebf419fe-f833-402a-9020-905d4c2c5aa7/image.png" alt=""></p>
<blockquote>
<h3 id="sttmanager">STTManager</h3>
</blockquote>
<pre><code class="language-swift">import Foundation
import Speech

@Observable
class STTManager {
    // 음성 엔진: 오디오 입력(마이크)을 처리하고 전달하는 역할
    private var audioEngine = AVAudioEngine()
    // 음성 인식기: 설정된 언어(ko-KR)에 따라 음성을 텍스트로 변환
    private var speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: &quot;ko-KR&quot;))
    // 인식 요청 객체: 실시간 음성 버퍼를 담아 전달하는 용도
    private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
    // 인식 작업 상태: 현재 진행 중인 음성 인식 작업의 핸들러
    private var recognitionTask: SFSpeechRecognitionTask?

    var transcript: String = &quot;&quot; // 변환된 텍스트가 저장되는 변수

    func startTranscribing() {
        reset() // 1. 이전 작업이 남아있을 수 있으므로 초기화

        let audioSession = AVAudioSession.sharedInstance() // 오디오 컨트롤 타워를 불러옵니다.
        // 2. 오디오 세션 설정: 녹음 모드로 설정하고, 데이터 측정(measurement)모드, 다른 앱의 소리를 줄임(duckOthers)
        try? audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
        try? audioSession.setActive(true, options: .notifyOthersOnDeactivation)

        // 3. 버퍼 기반 리퀘스트 생성 (실시간 마이크 입력용)
        recognitionRequest = SFSpeechAudioBufferRecognitionRequest()

        guard let recognitionRequest = recognitionRequest else { return }
        // 4. 부분 결과 보고: true 설정 시 문장이 끝나기 전에도 실시간 인식 결과를 반환함
        recognitionRequest.shouldReportPartialResults = true

        let inputNode = audioEngine.inputNode

        // 5. 음성 인식 작업 시작
        recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { result, error in
            if let result = result {
                // 실시간으로 변환된 가장 가능성 높은 문장을 업데이트
                self.transcript = result.bestTranscription.formattedString
            }

            // 에러가 발생하거나 최종 인식이 완료되면 중지
            if error != nil || result?.isFinal == true {
                self.stopTranscribing()
            }
        }

        // 6. 마이크 입력을 리퀘스트에 연결 (탭 설치)
        let recordingFormat = inputNode.outputFormat(forBus: 0)
        inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
            // 오디오 버퍼를 실시간으로 인식 요청 객체에 추가
            recognitionRequest.append(buffer)
        }

        // 7. 오디오 엔진 가동
        try? audioEngine.start()
    }

    func stopTranscribing() {
        audioEngine.stop() // 엔진 정지
        audioEngine.inputNode.removeTap(onBus: 0) // 설치했던 탭 제거
        recognitionRequest?.endAudio() // 리퀘스트 종료 알림
        recognitionTask?.cancel() // 태스크 취소
    }

    private func reset() {
        transcript = &quot;&quot;
        recognitionTask?.cancel()
        recognitionTask = nil
    }

    // 권한 요청: Info.plist에 Privacy - Speech Recognition... 설정이 필수입니다.
    func requestPermissions() {
        SFSpeechRecognizer.requestAuthorization { authStatus in
            DispatchQueue.main.async {
                switch authStatus {
                case .authorized: print(&quot;음성 인식 허용됨&quot;)
                case .denied: print(&quot;사용자가 거부함&quot;)
                case .restricted, .notDetermined: print(&quot;권한 설정 필요&quot;)
                @unknown default: break
                }
            }
        }
    }
}</code></pre>
<blockquote>
<h3 id="view">View</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct TestView: View {
    @State private var sttManager = STTManager()
    @State private var isRecording = false

    var body: some View {
        VStack(spacing: 30) {
            Text(&quot;실시간 음성 인식&quot;)
                .font(.title).bold()

            ScrollView {
                Text(sttManager.transcript)
                    .font(.body)
                    .padding()
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(height: 300)
            .background(Color.secondary.opacity(0.1))
            .cornerRadius(10)

            Button(action: {
                if isRecording {
                    sttManager.stopTranscribing()
                } else {
                    sttManager.startTranscribing()
                }
                isRecording.toggle()
            }) {
                Circle()
                    .fill(isRecording ? .red : .blue)
                    .frame(width: 70, height: 70)
                    .overlay(
                        Image(systemName: isRecording ? &quot;stop.fill&quot; : &quot;mic.fill&quot;)
                            .foregroundColor(.white)
                            .font(.title)
                    )
            }
        }
        .padding()
        .onAppear {
            sttManager.requestPermissions()
        }
    }
}
</code></pre>
<blockquote>
<h3 id="구현-영상">구현 영상</h3>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/2c9173d4-620a-4b62-92fb-cb28cebc8f15/image.gif" alt=""></p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/speech/sfspeechrecognizer">https://developer.apple.com/documentation/speech/sfspeechrecognizer</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] AVSpeechSynthesizer로 TTS(Text To Speech) 구현하기 (2)]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-AVSpeechSynthesizer%EB%A1%9C-TTSText-To-Speech-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-AVSpeechSynthesizer%EB%A1%9C-TTSText-To-Speech-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2</guid>
            <pubDate>Mon, 16 Feb 2026 12:32:56 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 TTS기능으로 텍스트를 읽어줄 때 해당 단어에 하이라이트 효과를 줘보려고 한다.</p>
<p>그러기 위해서는 <code>AVSpeechSynthesizerDelegate</code> 을 알아야 한다.</p>
<blockquote>
<h3 id="avspeechsynthesizerdelegate">AVSpeechSynthesizerDelegate</h3>
</blockquote>
<p>AVSpeechSynthesizerDelegate 프로토콜은 크게 세 가지 카테고리의 이벤트를 전달해 준다.</p>
<ul>
<li>음성 출력을 시작하거나 마쳤을 때</li>
<li>음성 출력을 멈췄다가 다시 재개할 때</li>
<li>각각의 음성 단위(일반적으로 단어)를 생성할 때</li>
</ul>
<br>
그리고 우리가 목표로 하는 '실시간 단어 하이라이트'를 위해서 3가지의 딜리게이트 메서드를 이용할 것이다.

<hr>
<p><strong>1. willSpeakRangeOfSpeechString</strong></p>
<pre><code class="language-swift">func speechSynthesizer(AVSpeechSynthesizer, willSpeakRangeOfSpeechString: NSRange, utterance: AVSpeechUtterance)</code></pre>
<p>이 메서드는 각 단어 단위를 말하기 직전에 호출된다.</p>
<p>인자로 넘어오는 <code>characterRange</code>가 바로 현재 읽으려는 단어가 전체 문장에서 어디에 위치하는지(<code>location</code>)와 길이(<code>length</code>)를 담고 있다. 이 값을 UI에 반영하면 실시간 하이라이트가 완성된다.</p>
<p><strong>2. didFinish</strong></p>
<pre><code class="language-swift">func speechSynthesizer(AVSpeechSynthesizer, didFinish: AVSpeechUtterance)</code></pre>
<p>설정한 모든 대본(Utterance)을 끝까지 다 읽었을 때 호출된다. 재생이 완전히 끝났으므로, 화면에 남아있는 하이라이트 효과를 제거하고 상태를 초기화하는 로직을 여기에 작성할 것이다.</p>
<p><strong>3. didCancel</strong></p>
<pre><code class="language-swift">func speechSynthesizer(AVSpeechSynthesizer, didCancel: AVSpeechUtterance)</code></pre>
<p>재생 중에 stopSpeaking(at:)이 호출되어 강제 종료되었을 때 호출된다. 사용자가 다른 텍스트를 탭하거나 재생을 중단했을 때, 남아있는 UI 효과를 즉시 지워주기 위해 필요하다.</p>
<br>
예제 코드와 함께 보겠다.

<blockquote>
<h3 id="speechmanager">SpeechManager</h3>
</blockquote>
<pre><code class="language-swift">import AVFAudio
import Foundation

@Observable 
final class SpeechManager: NSObject {
    private let synthesizer = AVSpeechSynthesizer()

    // 현재 재생 중인 텍스트 (어떤 문장을 하이라이트할지 결정)
    var activeText: String? = nil

    // 현재 읽고 있는 단어의 범위 (어느 부분을 빨간색으로 칠할지 결정)
    var highlightedRange: NSRange? = nil

    // 현재 실제로 재생되고 있는 &#39;대본 객체&#39;를 저장합니다.
    // @ObservationIgnored는 이 값이 바뀌어도 뷰를 다시 그릴 필요가 없을 때 사용합니다.
    @ObservationIgnored private var activeUtterance: AVSpeechUtterance?

    override init() {
        super.init()
        synthesizer.delegate = self
    }

    /// 음성 재생 시작 함수
    func play(_ text: String) {
        // 1. 이미 재생 중인 음성이 있다면 즉시 중단합니다.
        if synthesizer.isSpeaking {
            synthesizer.stopSpeaking(at: .immediate)
        }

        // 2. 대본(Utterance) 생성 및 설정
        let utterance = AVSpeechUtterance(string: text)
        utterance.rate = 0.5 // 말하기 속도 (0.0 ~ 1.0)
        utterance.voice = AVSpeechSynthesisVoice(language: &quot;ko-KR&quot;) // 한국어 설정

        // 3. 현재 상태 업데이트
        self.activeText = text
        self.activeUtterance = utterance
        self.highlightedRange = nil // 재생 시작 전이므로 하이라이트 초기화

        // 4. 재생 명령
        synthesizer.speak(utterance)
    }

    /// 하이라이트 상태를 모두 지우는 헬퍼 함수
    private func clearState() {
        self.activeText = nil
        self.highlightedRange = nil
        self.activeUtterance = nil
    }
}

// MARK: - AVSpeechSynthesizerDelegate
// 음성 재생 도중 발생하는 이벤트들을 수신하는 곳입니다.
extension SpeechManager: AVSpeechSynthesizerDelegate {

    /// [단어 단위 추적] 특정 범위의 단어를 말하기 직전에 호출됩니다.
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        // 지금 들어온 이벤트가 &#39;가장 최근에 요청한 재생&#39; 건인지 확인합니다.
        // 연타 시 이전 음성의 이벤트가 새 음성의 하이라이트를 망치지 않게 합니다.
        guard self.activeUtterance == utterance else { return }

        // 현재 읽고 있는 위치를 업데이트합니다.
        self.highlightedRange = characterRange
    }

    /// [완료 이벤트] 텍스트를 끝까지 다 읽었을 때 호출됩니다.
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        // 방금 끝난 게 현재 재생 중인 대본이 맞다면 상태를 초기화합니다.
        guard self.activeUtterance == utterance else { return }
        self.clearState()
    }

    /// [취소 이벤트] stopSpeaking() 등으로 인해 재생이 중단되었을 때 호출됩니다.
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        // 재생이 취소되었으므로 하이라이트를 지워줍니다.
        guard self.activeUtterance == utterance else { return }
        self.clearState()
    }
}</code></pre>
<p>여기서 핵심은 AVSpeechSynthesizerDelegate 메서드인 <code>willSpeakRangeOfSpeechString</code> 에서 받아온 characterRange를 통해서 이제 어느 단어를 읽을건지 업데이트 해준다.</p>
<blockquote>
<h3 id="ttsdemoview">TTSDemoView</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct TTSDemoView: View {
    @State private var speechManager = SpeechManager()

    let sentences = [
        &quot;안녕하세요, 첫 번째 테스트 문장입니다.&quot;,
        &quot;리스트를 탭하면 음성 재생과 하이라이트가 시작됩니다.&quot;,
        &quot;연타를 해도 로직이 꼬이지 않고 부드럽게 재생됩니다.&quot;
    ]

    var body: some View {
        NavigationStack {
            List(sentences, id: \.self) { text in
                VStack(alignment: .leading) {
                    // 스타일이 적용된 텍스트
                    Text(highlightedText(for: text))
                        .font(.system(size: 18, weight: .medium))
                        .padding(.vertical, 8)
                }
                .contentShape(Rectangle())
                .onTapGesture {
                    // 단순하게 텍스트만 전달해서 재생
                    speechManager.play(text)
                }
            }
            .navigationTitle(&quot;TTS 하이라이트&quot;)
        }
    }

    // MARK: - 하이라이트 로직
    private func highlightedText(for text: String) -&gt; AttributedString {
        var attrString = AttributedString(text)

        // 현재 매니저가 읽고 있는 텍스트와 이 줄의 텍스트가 같을 때만 빨간색 적용
        if speechManager.activeText == text,
           let range = speechManager.highlightedRange,
           let attrRange = Range(range, in: attrString) {
            attrString[attrRange].foregroundColor = .red
        }

        return attrString
    }
}


#Preview {
    TTSDemoView()
}</code></pre>
<p>여기서 핵심은 <code>func highlightedText(for text: String)</code> 함순데 이 친구가 SpeechManager가 전달해 주는 <strong>&#39;숫자 데이터(NSRange)&#39;</strong>를 시각적인 <strong>&#39;빨간색 하이라이트&#39;</strong>로 변환하는 다리 역할을 한다. 
즉, delegate로 받아온 characterRange로 현재 TTS로 읽어주는 위치가 빨간색 글씨로 보인다는 것이다.</p>
<ul>
<li>참고로 <code>Range(range, in: attrString)</code>를 사용하여 Objective-C 방식의 숫자 위치(<code>NSRange</code>)를 Swift 방식의 인덱스 범위(<code>Range</code>)로 변환하고 있다.</li>
</ul>
<p>그렇게 문장 안에 TTS로 읽어주고 있는 attrRange범위가 빨간색으로 변한다.</p>
<blockquote>
<h3 id="테스트-영상">테스트 영상</h3>
</blockquote>
<p>소리는 안나오지만 각 리스트를 탭하면 TTS가 읽어주는 그 위치가 빨간색으로 변하게 된다.<img src="https://velog.velcdn.com/images/sheep_jh/post/19189973-0a96-4cb7-a699-d1f0340de628/image.gif" alt=""></p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/avfaudio/avspeechsynthesizerdelegate">https://developer.apple.com/documentation/avfaudio/avspeechsynthesizerdelegate</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] AVSpeechSynthesizer로 TTS(Text To Speech) 구현하기 (1)]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-AVSpeechSynthesizer%EB%A1%9C-TTSText-To-Speech-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-AVSpeechSynthesizer%EB%A1%9C-TTSText-To-Speech-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 08:49:23 GMT</pubDate>
            <description><![CDATA[<p>Apple의 AVFAudio 프레임워크 내 <strong>Speech Synthesis</strong>는 텍스트를 분석하여 인공적인 음성을 생성하는 강력한 기능을 제공한다. 이 글에서는 공식 문서를 바탕으로 가장 핵심적인 3가지 객체를 활용해 TTS 기능을 구현해 보겠다.</p>
<blockquote>
<h3 id="핵심-클래스">핵심 클래스</h3>
</blockquote>
<p><strong>1. AVSpeechUtterance(대본) :</strong> 어떤 내용을 읽을지, 어떤 속도, 어떤 음높이로 읽을 지 등을 결정한다. </p>
<p><strong>2. AVSpeechSynthesisVoice(성우)</strong> : 한국어 성우, 영어 성우 등 목소리를 결정한다.</p>
<p><strong>3. AVSpeechSynthesizer(연출가)</strong> : 실제로 음성을 출력하고 제어(재생/일시정지/정지)하는 역할을 한다.</p>
<br>
바로 코드 예제와 함께 보겠다.

<blockquote>
<h3 id="speechmanager">SpeechManager</h3>
</blockquote>
<pre><code class="language-swift">final class SpeechManager {

    static let shared = SpeechManager()

    private let synthesizer = AVSpeechSynthesizer() // 연출가 생성

    private init() {}

    // 1. AVSpeechUtterance(대본)
    private let text = &quot;안녕하세요. SwiftUI에서 TTS를 테스트합니다.&quot;
    private var utterance: AVSpeechUtterance {
        let u = AVSpeechUtterance(string: text)
        u.rate = 0.5 // 말하기 속도
        u.volume = 1.0 // 음 높낮이

        // 2. AVSpeechSynthesisVoice(성우)
        u.voice = AVSpeechSynthesisVoice(language: &quot;ko-KR&quot;) // 한국어 성우
        return u
    }

    // 3. AVSpeechSynthesizer(연출가)
    /// 재생
    func play() {
        if synthesizer.isPaused {
            synthesizer.continueSpeaking() // 일시정지 상태면 계속 진행
        } else {
            synthesizer.speak(utterance)
        }
    }

    /// 일시정지
    func pause() {
        synthesizer.pauseSpeaking(at: .word) //지금 읽고 있는 단어까지만 읽어
    }

    /// 정지
    func stop() {
        synthesizer.stopSpeaking(at: .immediate) //즉시 정지
    }
}
</code></pre>
<p><strong>AVSpeechSynthesizer(연출가)</strong>를 먼저 불러온다. 그 후 <strong>AVSpeechUtterance(대본)</strong>을 보고 말하기 속도를 어떻게 가져가야 하는지, 음 높낮이는 어떻게 가져가야 하는지 성우를 위한 대본을 짜준다. 
그러고 나서 <strong>AVSpeechSynthesisVoice(성우)</strong>를 한국인 성우로 모셔온다.</p>
<p>그 후 연출가에 지휘에 따라 성우가 대본을 읽던가 <code>speak()</code> 잠시 멈추던가 <code>pauseSpeaking()</code> 아예 멈추던가 <code>stopSpeaking</code> 할 수 있는 것이다.</p>
<p>(참고로 .word는 지금 읽고 있는 단어까지만 읽고 멈추고, .immediate는 즉시 멈춘다)</p>
<blockquote>
<h3 id="testview">TestView</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct TestView: View {

    var body: some View {
        VStack(spacing: 30) {

            Button(&quot;재생&quot;) {
                SpeechManager.shared.play()
            }

            Button(&quot;일시정지&quot;) {
                SpeechManager.shared.pause()
            }

            Button(&quot;정지&quot;) {
                SpeechManager.shared.stop()
            }
        }
        .padding()
    }
}

#Preview {
    TestView()
}</code></pre>
<p>이런식으로 SwiftUI에서 테스트 뷰를 통해서 TTS를 구현할 수 있다.</p>
<p><br><br></p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/avfaudio/speech-synthesis">https://developer.apple.com/documentation/avfaudio/speech-synthesis</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Swift/UIKit] Scribble Sticker 손글씨 스티커 구현]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUIKit-Scribble-Sticker-%EC%86%90%EA%B8%80%EC%94%A8-%EC%8A%A4%ED%8B%B0%EC%BB%A4-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@sheep_jh/SwiftUIKit-Scribble-Sticker-%EC%86%90%EA%B8%80%EC%94%A8-%EC%8A%A4%ED%8B%B0%EC%BB%A4-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 15 Feb 2026 15:35:11 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 아이패드에서 아이펜슬로 손글씨를 쓸때 Scribble로 텍스트로 바로 변환시키는 예제를 구현해보겠다.</p>
<p>과정은 아래와 같이 진행된다.</p>
<pre><code>1. 빈뷰에 손글씨 작성
2. Scribble로 해당 위치에 텍스트필드 생성
3. 텍스트필드를 텍스트로 변경</code></pre><p>바로 코드로 진행하겠다.</p>
<blockquote>
<h3 id="stickertextfield">StickerTextField</h3>
</blockquote>
<pre><code class="language-swift">class StickerTextField: UITextField {

    var fontSize: CGFloat = 28.0  // 기본 글씨 크기 설정
    let identifier = UUID() // 여러 스티커를 구분하기 위한 id

    // 코드로 UI를 만들 때 필수로 구현해야 하는 부분 (스토리보드 사용 안 하니까 에러 처리)
    required init?(coder: NSCoder) {
        fatalError(&quot;Not implemented&quot;)
    }

    // 텍스트 필드가 처음 만들어질 때 초기 설정
    override init(frame: CGRect) {
        super.init(frame: frame)
        text = &quot;&quot; // 처음엔 빈칸
        borderStyle = .roundedRect // 둥근 테두리 모양
        backgroundColor = .clear // 배경은 투명하게
        updateFont() // 폰트 설정 적용
        updateSize() // 크기 설정 적용
    }

    // 위치(origin)만 알려주면 기본 크기(12x20)로 만들어주는 편리한 초기화 방법
    convenience init(origin: CGPoint) {
        self.init(frame: CGRect(origin: origin, size: CGSize(width: 12, height: 20)))
    }

    // 폰트를 설정하고, 글씨 크기에 맞게 텍스트 필드 크기도 다시 맞추는 함수
    func updateFont() {
        font = UIFont(name: &quot;Futura-Bold&quot;, size: fontSize)
        updateSize(centerResize: true)
    }

    // 글자가 써질 때마다 텍스트 필드의 크기를 자동으로 조절해주는 함수
    func updateSize(centerResize: Bool = false) {
        let oldSize = frame.size
        // 폰트 크기를 기준으로 얼만큼의 공간이 필요한지 계산해요.
        let size = sizeThatFits(CGSize(width: 1024, height: fontSize))
        let oldOrigin = frame.origin

        let deltaX = size.width - oldSize.width
        let deltaY = size.height - oldSize.height

        // centerResize가 true면 중앙을 기준으로 크기를 늘리고, 아니면 원래 위치를 유지해요.
        let origin = centerResize ? CGPoint(x: oldOrigin.x - deltaX / 2, y: oldOrigin.y - deltaY / 2) : oldOrigin
        frame = CGRect(origin: origin, size: size)
    }
}
</code></pre>
<ul>
<li>StickerTextField, 말 그대로 스티커처럼 붙일 텍스트 필드다. </li>
<li>나중에 빈뷰에서 아무곳에나 손글씨를 쓰면 이 스티커 텍스트 필드가 만들어질 것이다.</li>
</ul>
<blockquote>
<h3 id="viewcontroller">ViewController</h3>
</blockquote>
<pre><code class="language-swift">class ViewController: UIViewController {
    var stickerTextFields: [StickerTextField] = [] // 스티커(텍스트 필드)들을 모아두는 배열
    var stickerContainerView = UIView() // 스티커들을 올려둘 커다란 투명 판(뷰)
    let rootViewElementID = UUID() // 투명 판(뷰)을 가리키는 고유 ID

    override func viewDidLoad() {
        super.viewDidLoad()

        // 투명 판(뷰)을 화면 전체 크기로 맞추고 화면에 추가합니다.
        stickerContainerView.frame = view.bounds
        stickerContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(stickerContainerView)

        // 손글씨(Scribble) 딜리게이트 부여
        let indirectScribbleInteraction = UIIndirectScribbleInteraction(delegate: self)
        stickerContainerView.addInteraction(indirectScribbleInteraction)

    }

    // 텍스트 필드에 글자가 바뀔 때마다 실행되는 함수
    @objc func handleTextFieldDidChange(_ textField: UITextField) {
        guard let stickerField = textField as? StickerTextField else { return }
            stickerField.updateSize() // 글자 바뀔때마다 텍스트필드 크기 바꿔줌
    }

    // 텍스트 필드를 텍스트로 바꿔줌
    func transformToLabel(_ stickerField: StickerTextField) {
        guard let text = stickerField.text else { return }

        let label = UILabel()
            label.text = text
            label.font = stickerField.font
            label.textColor = .black // 원하는 글자 색상
            label.sizeToFit() // 글자 크기에 딱 맞게 조절
            label.center = stickerField.center // 텍스트 필드가 있던 위치 그대로

        // 화면에 텍스트를 추가하고, 기존의 텍스트 필드(스티커)는 지워버립니다.
        stickerContainerView.addSubview(label)
        stickerField.removeFromSuperview()
    }

    // 특정 위치에 새로운 스티커(텍스트 필드)를 만들어서 추가하는 함수
    func addStickerFieldAtLocation(_ location: CGPoint) -&gt; StickerTextField {
        let stickerField = StickerTextField(origin: location)
        stickerField.delegate = self
        // 글자가 바뀔 때마다 감지하도록 설정
        stickerField.addTarget(self, action: #selector(handleTextFieldDidChange(_:)), for: .editingChanged)

        stickerTextFields.append(stickerField) // 배열에 저장
        stickerContainerView.addSubview(stickerField) // 화면에 표시
        return stickerField
    }

}</code></pre>
<ul>
<li>메인 화면이 될 ViewController다.</li>
<li>스티커 텍스트 필드가 붙여질 화이트 보드라고 생각하면되는데, 메서드를 하나씩 보자면</li>
<li><code>objc func handleTextFieldDidChange(_ textField: UITextField)</code> 이 친구는 글자가 늘어날수록 <strong>텍스트 필드도 같이 커지게</strong> 해주는 메서드다.</li>
<li><code>func transformToLabel(_ stickerField: StickerTextField)</code> 이 친구는 보드판에 붙인 스티커 텍스트 필드를 <strong>일반 텍스트로 바꿔주는 역할</strong>을 한다. </li>
<li><code>func addStickerFieldAtLocation(_ location: CGPoint)</code> 이 친구는 보드판 아무데서나 <strong>새로운 스티커 텍스트 필드를 추가</strong>할 수 있게 해주는 메서드다.</li>
</ul>
<blockquote>
<h3 id="uiindirectscribbleinteractiondelegate간접">UIIndirectScribbleInteractionDelegate(간접)</h3>
</blockquote>
<pre><code class="language-swift">extension ViewController: UIIndirectScribbleInteractionDelegate {

    // 손글씨를 쓸 수 있는 대상이 뭐뭐가 있는지
    func indirectScribbleInteraction(_ interaction: UIInteraction, requestElementsIn rect: CGRect, completion: @escaping ([ElementIdentifier]) -&gt; Void) {
            completion([rootViewElementID])
        }

    // Scribble이 활성화되는 구역
    func indirectScribbleInteraction(_ interaction: UIInteraction, frameForElement elementIdentifier: UUID) -&gt; CGRect {
        return stickerContainerView.frame
    }

    // 이 ID를 가진 필드가 이미 포커스 상태인지
    func indirectScribbleInteraction(_ interaction: UIInteraction, isElementFocused elementIdentifier: UUID) -&gt; Bool {
       return false
    }

    // 펜슬이 닿는 순간 그 위치(focusReferencePoint)에 해당하는 텍스트 필드 생성
    func indirectScribbleInteraction(_ interaction: UIInteraction, focusElementIfNeeded elementIdentifier: UUID, referencePoint focusReferencePoint: CGPoint, completion: @escaping ((UIResponder &amp; UITextInput)?) -&gt; Void) {
            let stickerField = addStickerFieldAtLocation(focusReferencePoint) // 펜슬 닿은 그 위치에 새 스티커를 만들기
            stickerField.becomeFirstResponder() // 포커스 활성화
            completion(stickerField) // 텍스트 필드에 방금 쓴 글씨 입력
    }

    // 빈 배경에 글씨 쓸 때 포커스 조금 뜸들이기 (false시 텍스트 필드 커서가 깜빡이다 사라짐)
    func indirectScribbleInteraction(_ interaction: UIInteraction, shouldDelayFocusForElement elementIdentifier: UUID) -&gt; Bool {
        return true
    }

    // 손글씨가 끝났을때
    func indirectScribbleInteraction(_ interaction: UIInteraction, didFinishWritingInElement elementIdentifier: UUID) {
        view.endEditing(true) // 이 뷰 안에 있는 모든 입력창(키보드)을 한 번에 다 닫기
    }
}</code></pre>
<ul>
<li><p>extension으로 UIIndirectScribbleInteractionDelegate를 받아준다.</p>
</li>
<li><p>각각의 메서드는 보드판에서 애플펜슬로 Scribble 상호작용에 관한 모든 내용을 담고 있다.</p>
</li>
<li><p><code>손글씨를 쓸 수 있는 대상</code>부터 <code>활성화되는 구역</code>, <code>포커스 상태 여부</code>, <code>펜슬이 닿는 위치</code>, <code>포커스 지연</code>, <code>손글씨 작성 완료</code> 까지 delegate를 수행한다.</p>
</li>
</ul>
<blockquote>
<h3 id="uitextfielddelegate">UITextFieldDelegate</h3>
</blockquote>
<pre><code class="language-swift">extension ViewController: UITextFieldDelegate {
    // 글씨 입력이 완전히 끝났을 때 자동으로 불리는 함수
    func textFieldDidEndEditing(_ textField: UITextField) {
        guard let stickerField = textField as? StickerTextField else { return } //StickerTextField로 만든 텍스트 필드 맞는지 확인

        transformToLabel(stickerField) //텍스트로 변환
    }
}</code></pre>
<ul>
<li>UITextField의 대리자다. </li>
<li>하나의 메서드만 있는데 <code>func textFieldDidEndEditing(_ textField: UITextField)</code> 는 손글씨가 끝나고 텍스트 필드가 닫혔을때 자동으로 불리게된다. 그 후 텍스트 필드에 써져있는 글자를 <strong>텍스트로 변환</strong>해주고 있다.</li>
</ul>
<blockquote>
<h3 id="전체코드">전체코드</h3>
</blockquote>
<pre><code class="language-swift">import UIKit

// MARK: - 1. 스티커 역할을 하는 텍스트 필드 (StickerTextField)
/*
 스티커TextField는 화면에 글씨를 쓰면 나타나는 텍스트 입력창입니다.
 내용이 길어지면 자동으로 크기가 늘어나고, 애플 펜슬로 대충 근처에 써도(Scribble)
 인식이 잘 되도록 인식 범위를 넓혀놓은 똑똑한 텍스트 필드예요.
 */
class StickerTextField: UITextField {

    var fontSize: CGFloat = 28.0  // 기본 글씨 크기 설정
    let identifier = UUID() // 여러 스티커를 구분하기 위한 id

    // 코드로 UI를 만들 때 필수로 구현해야 하는 부분 (스토리보드 사용 안 하니까 에러 처리)
    required init?(coder: NSCoder) {
        fatalError(&quot;Not implemented&quot;)
    }

    // 텍스트 필드가 처음 만들어질 때 초기 설정
    override init(frame: CGRect) {
        super.init(frame: frame)
        text = &quot;&quot; // 처음엔 빈칸
        borderStyle = .roundedRect // 둥근 테두리 모양
        backgroundColor = .clear // 배경은 투명하게
        updateFont() // 폰트 설정 적용
        updateSize() // 크기 설정 적용
    }

    // 위치(origin)만 알려주면 기본 크기(12x20)로 만들어주는 편리한 초기화 방법
    convenience init(origin: CGPoint) {
        self.init(frame: CGRect(origin: origin, size: CGSize(width: 12, height: 20)))
    }

    // 폰트를 설정하고, 글씨 크기에 맞게 텍스트 필드 크기도 다시 맞추는 함수
    func updateFont() {
        font = UIFont(name: &quot;Futura-Bold&quot;, size: fontSize)
        updateSize(centerResize: true)
    }

    // 글자가 써질 때마다 텍스트 필드의 크기를 자동으로 조절해주는 함수
    func updateSize(centerResize: Bool = false) {
        let oldSize = frame.size
        // 폰트 크기를 기준으로 얼만큼의 공간이 필요한지 계산해요.
        let size = sizeThatFits(CGSize(width: 1024, height: fontSize))
        let oldOrigin = frame.origin

        let deltaX = size.width - oldSize.width
        let deltaY = size.height - oldSize.height

        // centerResize가 true면 중앙을 기준으로 크기를 늘리고, 아니면 원래 위치를 유지해요.
        let origin = centerResize ? CGPoint(x: oldOrigin.x - deltaX / 2, y: oldOrigin.y - deltaY / 2) : oldOrigin
        frame = CGRect(origin: origin, size: size)
    }
}

// MARK: - 2. 메인 화면 (ViewController)
class ViewController: UIViewController {
    var stickerTextFields: [StickerTextField] = [] // 스티커(텍스트 필드)들을 모아두는 배열
    var stickerContainerView = UIView() // 스티커들을 올려둘 커다란 투명 판(뷰)
    let rootViewElementID = UUID() // 투명 판(뷰)을 가리키는 고유 ID

    override func viewDidLoad() {
        super.viewDidLoad()

        // 투명 판(뷰)을 화면 전체 크기로 맞추고 화면에 추가합니다.
        stickerContainerView.frame = view.bounds
        stickerContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(stickerContainerView)

        // 손글씨(Scribble) 딜리게이트 부여
        let indirectScribbleInteraction = UIIndirectScribbleInteraction(delegate: self)
        stickerContainerView.addInteraction(indirectScribbleInteraction)

    }

    // 텍스트 필드에 글자가 바뀔 때마다 실행되는 함수
    @objc func handleTextFieldDidChange(_ textField: UITextField) {
        guard let stickerField = textField as? StickerTextField else { return }
            stickerField.updateSize() // 글자 바뀔때마다 텍스트필드 크기 바꿔줌
    }

    // 텍스트 필드를 텍스트로 바꿔줌
    func transformToLabel(_ stickerField: StickerTextField) {
        guard let text = stickerField.text else { return }

        let label = UILabel()
            label.text = text
            label.font = stickerField.font
            label.textColor = .black // 원하는 글자 색상
            label.sizeToFit() // 글자 크기에 딱 맞게 조절
            label.center = stickerField.center // 텍스트 필드가 있던 위치 그대로

        // 화면에 텍스트를 추가하고, 기존의 텍스트 필드(스티커)는 지워버립니다.
        stickerContainerView.addSubview(label)
        stickerField.removeFromSuperview()
    }

    // 특정 위치에 새로운 스티커(텍스트 필드)를 만들어서 추가하는 함수
    func addStickerFieldAtLocation(_ location: CGPoint) -&gt; StickerTextField {
        let stickerField = StickerTextField(origin: location)
        stickerField.delegate = self
        // 글자가 바뀔 때마다 감지하도록 설정
        stickerField.addTarget(self, action: #selector(handleTextFieldDidChange(_:)), for: .editingChanged)

        stickerTextFields.append(stickerField) // 배열에 저장
        stickerContainerView.addSubview(stickerField) // 화면에 표시
        return stickerField
    }

}


// MARK: - UIIndirectScribbleInteractionDelegate(간접) 델리게이트
extension ViewController: UIIndirectScribbleInteractionDelegate {

    // 손글씨를 쓸 수 있는 대상이 뭐뭐가 있는지
    func indirectScribbleInteraction(_ interaction: UIInteraction, requestElementsIn rect: CGRect, completion: @escaping ([ElementIdentifier]) -&gt; Void) {
            completion([rootViewElementID])
        }

    // Scribble이 활성화되는 구역
    func indirectScribbleInteraction(_ interaction: UIInteraction, frameForElement elementIdentifier: UUID) -&gt; CGRect {
        return stickerContainerView.frame
    }

    // 이 ID를 가진 필드가 이미 포커스 상태인지
    func indirectScribbleInteraction(_ interaction: UIInteraction, isElementFocused elementIdentifier: UUID) -&gt; Bool {
       return false
    }

    // 펜슬이 닿는 순간 그 위치(focusReferencePoint)에 해당하는 텍스트 필드 생성
    func indirectScribbleInteraction(_ interaction: UIInteraction, focusElementIfNeeded elementIdentifier: UUID, referencePoint focusReferencePoint: CGPoint, completion: @escaping ((UIResponder &amp; UITextInput)?) -&gt; Void) {
            let stickerField = addStickerFieldAtLocation(focusReferencePoint) // 펜슬 닿은 그 위치에 새 스티커를 만들기
            stickerField.becomeFirstResponder() // 포커스 활성화
            completion(stickerField) // 텍스트 필드에 방금 쓴 글씨 입력
    }

    // 빈 배경에 글씨 쓸 때 포커스 조금 뜸들이기 (false시 텍스트 필드 커서가 깜빡이다 사라짐)
    func indirectScribbleInteraction(_ interaction: UIInteraction, shouldDelayFocusForElement elementIdentifier: UUID) -&gt; Bool {
        return true
    }

    // 손글씨가 끝났을때
    func indirectScribbleInteraction(_ interaction: UIInteraction, didFinishWritingInElement elementIdentifier: UUID) {
        view.endEditing(true) // 이 뷰 안에 있는 모든 입력창(키보드)을 한 번에 다 닫기
    }
}

// MARK: - UITextField 델리게이트
extension ViewController: UITextFieldDelegate {
    // 글씨 입력이 완전히 끝났을 때 자동으로 불리는 함수
    func textFieldDidEndEditing(_ textField: UITextField) {
        guard let stickerField = textField as? StickerTextField else { return } //StickerTextField로 만든 텍스트 필드 맞는지 확인

        transformToLabel(stickerField) //텍스트로 변환
    }
}</code></pre>
<blockquote>
<h3 id="테스트-영상">테스트 영상</h3>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/8ead1699-a8dd-4f17-92c9-22cf9c8a7dab/image.gif" alt=""></p>
<p>이처럼 Apple Pencil로 손글씨를 쓰면 자동으로 텍스트로 변환 해주는 예제를 시현해보았다.</p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
<p><a href="https://developer.apple.com/documentation/pencilkit/customizing-scribble-with-interactions">https://developer.apple.com/documentation/pencilkit/customizing-scribble-with-interactions</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Swift/UIKit] Handwriting recognition 필기체 인식 (2)]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUIUIKit-Handwriting-recognition-%ED%95%84%EA%B8%B0%EC%B2%B4-%EC%9D%B8%EC%8B%9D-2</link>
            <guid>https://velog.io/@sheep_jh/SwiftUIUIKit-Handwriting-recognition-%ED%95%84%EA%B8%B0%EC%B2%B4-%EC%9D%B8%EC%8B%9D-2</guid>
            <pubDate>Sun, 15 Feb 2026 06:11:03 GMT</pubDate>
            <description><![CDATA[<p>이전 글에서 UIScribbleInteraction(직접)에 대해 알아봤고 이번 글에서는 <strong>UIIndirectScribbleInteraction(간접)</strong>에 대해 알아보겠다.</p>
<blockquote>
<h3 id="uiindirectscribbleinteraction간접">UIIndirectScribbleInteraction(간접)</h3>
</blockquote>
<p>UITextField나처럼 정식적인 텍스트 입력 UI가 아닌 곳에서도 사용자가 손글씨를 써서 텍스트를 입력할 수 있도록 지원해주는 클래스다.</p>
<p><code>주요 역할</code>
<strong>간접 입력(Indirect Input) 처리</strong>: 실제 텍스트 입력 필드는 아니지만, 특정 영역을 &quot;글씨 쓰기가 가능한 영역&quot;으로 정의하고 Scribble 엔진과 연결해 준다.</p>
<hr>
<h3 id="주요-델리게이트-메서드-uiindirectscribbleinteractiondelegate">주요 델리게이트 메서드 (UIIndirectScribbleInteractionDelegate)</h3>
<p><strong>1. 포커스 관리</strong></p>
<pre><code class="language-swift">func indirectScribbleInteraction(any UIInteraction, isElementFocused: Self.ElementIdentifier) -&gt; Bool
func indirectScribbleInteraction(any UIInteraction, focusElementIfNeeded: Self.ElementIdentifier, referencePoint: CGPoint, completion: ((any UIResponder &amp; UITextInput)?) -&gt; Void)
func indirectScribbleInteraction(any UIInteraction, shouldDelayFocusForElement: Self.ElementIdentifier) -&gt; Bool</code></pre>
<p><code>isElementFocused</code></p>
<ul>
<li><p>역할: 특정 요소가 현재 포커스 상태인지 물어볼 때 사용</p>
</li>
<li><p>반환: 포커스 되어 있다면 true, 아니면 false.</p>
</li>
</ul>
<p><code>focusElementIfNeeded</code></p>
<ul>
<li><p>역할: 시스템이 특정 요소를 활성화(Focus)하라고 요청할 때 호출됨</p>
</li>
<li><p>핵심: 여기서 실제 텍스트 입력을 담당할 객체(UITextInput을 채택한 객체)를 completion 클로저를 통해 전달해야 함 (최신문법은 밑에 async으로)</p>
</li>
</ul>
<p><code>shouldDelayFocusForElement</code></p>
<ul>
<li>역할: 포커스 잡는 것을 잠시 미룰지 결정. 기본적으로는 바로 포커스가 잡히지만, 특정 상황에서 지연이 필요할 때 true를 반환</li>
</ul>
<hr>
<p><strong>2. Scribble 상태 추적</strong></p>
<pre><code class="language-swift">func indirectScribbleInteraction(any UIInteraction, willBeginWritingInElement: Self.ElementIdentifier)
func indirectScribbleInteraction(any UIInteraction, didFinishWritingInElement: Self.ElementIdentifier)</code></pre>
<p><code>willBeginWritingInElement</code></p>
<ul>
<li>역할: 사용자가 Apple Pencil로 글씨를 쓰기 시작할 때 호출됨</li>
</ul>
<p><code>didFinishWritingInElement</code></p>
<ul>
<li>역할: 사용자가 글씨 쓰기를 마쳤을 때 호출됨</li>
</ul>
<hr>
<p><strong>3. 요소 및 프레임 찾기</strong></p>
<pre><code class="language-swift">func indirectScribbleInteraction(any UIInteraction, frameForElement: Self.ElementIdentifier) -&gt; CGRect
func indirectScribbleInteraction(any UIInteraction, requestElementsIn: CGRect, completion: ([Self.ElementIdentifier]) -&gt; Void)</code></pre>
<p><code>frameForElement</code></p>
<ul>
<li>역할: 특정 ID를 가진 요소의 <strong>실제 위치와 크기(CGRect)</strong>를 시스템에 알려줍</li>
</ul>
<p><code>requestElementsIn</code></p>
<ul>
<li>역할: 지정된 영역(CGRect) 안에 있는 모든 텍스트 입력 요소의 ID들을 배열로 묶어서 반환함 (최신문법은 밑에 async으로)</li>
</ul>
<hr>
<p><strong>4. 최신 비동기 메서드 (Swift Concurrency)</strong></p>
<pre><code class="language-swift">func indirectScribbleInteraction(any UIInteraction, focusElementIfNeeded: Self.ElementIdentifier, referencePoint: CGPoint) async -&gt; (any UIResponder &amp; UITextInput)?
func indirectScribbleInteraction(any UIInteraction, requestElementsIn: CGRect) async -&gt; [Self.ElementIdentifier]</code></pre>
<p><code>focusElementIfNeeded (async)</code></p>
<ul>
<li>역할: 시스템이 전달해준 ID에 해당하는 실제 입력창 객체(UITextInput)를 찾아 반환</li>
</ul>
<p><code>requestElementsIn (async)</code></p>
<ul>
<li>역할: 지정된 영역(CGRect) 안에 있는 모든 텍스트 입력 요소의 ID들을 배열로 묶어서 반환함</li>
</ul>
<blockquote>
<h3 id="코드-예제">코드 예제</h3>
</blockquote>
<pre><code class="language-swift">import UIKit

class ScribbleFullExampleViewController: UIViewController {

    private let targetTextField = UITextField()
    private let scribbleCanvas = UIView()
    private let elementID = &quot;custom-memo-area&quot;

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()

        let interaction = UIIndirectScribbleInteraction(delegate: self)
        scribbleCanvas.addInteraction(interaction)
    }
}

// MARK: - UIIndirectScribbleInteractionDelegate
extension ScribbleFullExampleViewController: UIIndirectScribbleInteractionDelegate {

    // MARK: - [1. 요소 및 프레임 찾기]
    // 시스템이 어느 영역에서 Scribble을 작동시킬지 탐색하는 단계 (2개 메서드)

    /// 1-1. [요소 탐색] 사용자가 펜슬로 터치/탐색 중인 rect 안에 어떤 입력 요소(ID)가 있는지 보고합니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     requestElementsIn rect: CGRect,
                                     completion: @escaping ([String]) -&gt; Void) {
        print(&quot;\n--- 🔍 STEP 1: 요소 탐색 ---&quot;)
        print(&quot;📍 위치: (x: \(Int(rect.origin.x)), y: \(Int(rect.origin.y))), 크기: \(Int(rect.width))x\(Int(rect.height))&quot;)
        completion([elementID])
    }

    /// 1-2. [프레임 제공] 특정 ID를 가진 요소의 실제 활성 영역(CGRect)을 시스템에 알려줍니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     frameForElement elementIdentifier: String) -&gt; CGRect {
        let frame = scribbleCanvas.bounds
        print(&quot;📍 1-2. frameForElement: [\(elementIdentifier)] 영역 확정&quot;)
        return frame
    }


    // MARK: - [2. 포커스 관리]
    // 실제 입력창(TextField)과 펜슬 입력 영역을 연결하는 단계 (3개 메서드)

    /// 2-1. [상태 확인] 특정 요소가 현재 포커스(First Responder) 상태인지 확인합니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     isElementFocused elementIdentifier: String) -&gt; Bool {
        let focused = targetTextField.isFirstResponder
        print(&quot;📍 2-1. isElementFocused: \(focused)&quot;)
        return focused
    }

    /// 2-2. [활성화 요청] 실제 입력을 받을 객체(UITextInput)를 지정합니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     focusElementIfNeeded elementIdentifier: String,
                                     referencePoint: CGPoint,
                                     completion: @escaping ((any UIResponder &amp; UITextInput)?) -&gt; Void) {
        print(&quot;\n--- 🎯 STEP 2: 포커스 활성화 ---&quot;)
        print(&quot;📍 터치 지점: \(Int(referencePoint.x)), \(Int(referencePoint.y))&quot;)

        targetTextField.becomeFirstResponder()
        completion(targetTextField)
    }

    /// 2-3. [지연 결정] 포커스 시점을 늦춰야 할지 결정합니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     shouldDelayFocusForElement elementIdentifier: String) -&gt; Bool {
        print(&quot;📍 2-3. shouldDelayFocus: false (즉시 실행)&quot;)
        return false
    }


    // MARK: - [3. Scribble 상태 추적]
    // 글쓰기 시작과 끝에 맞춰 UI 피드백을 주는 단계 (2개 메서드)

    /// 3-1. [쓰기 시작] 사용자가 Apple Pencil로 글씨를 쓰기 시작할 때 호출됩니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     willBeginWritingInElement elementIdentifier: String) {
        print(&quot;\n--- ✍️ STEP 3: 쓰기 시작 ---&quot;)
        updateCanvasUI(isWriting: true)
    }

    /// 3-2. [쓰기 종료] 입력 및 텍스트 변환이 모두 끝난 시점입니다.
    func indirectScribbleInteraction(_ interaction: UIInteraction,
                                     didFinishWritingInElement elementIdentifier: String) {
        print(&quot;--- ✅ STEP 4: 쓰기 종료 ---\n&quot;)
        updateCanvasUI(isWriting: false)
    }

    private func updateCanvasUI(isWriting: Bool) {
        UIView.animate(withDuration: 0.2) {
            self.scribbleCanvas.layer.borderColor = isWriting ? UIColor.systemBlue.cgColor : UIColor.clear.cgColor
            self.scribbleCanvas.layer.borderWidth = isWriting ? 3 : 0
            self.scribbleCanvas.backgroundColor = isWriting ? .systemGray5 : .systemGray6
        }
    }
}

// MARK: - Layout Setup
extension ScribbleFullExampleViewController {
    private func setupUI() {
        view.backgroundColor = .white

        scribbleCanvas.backgroundColor = .systemGray6
        scribbleCanvas.layer.cornerRadius = 20
        scribbleCanvas.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(scribbleCanvas)

        let label = UILabel()
        label.text = &quot;여기에 펜슬로 써보세요&quot;
        label.font = .systemFont(ofSize: 14, weight: .medium)
        label.textColor = .systemGray
        label.translatesAutoresizingMaskIntoConstraints = false
        scribbleCanvas.addSubview(label)

        targetTextField.borderStyle = .roundedRect
        targetTextField.placeholder = &quot;결과창&quot;
        targetTextField.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(targetTextField)

        NSLayoutConstraint.activate([
            scribbleCanvas.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            scribbleCanvas.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -50),
            scribbleCanvas.widthAnchor.constraint(equalToConstant: 350),
            scribbleCanvas.heightAnchor.constraint(equalToConstant: 250),

            label.centerXAnchor.constraint(equalTo: scribbleCanvas.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: scribbleCanvas.centerYAnchor),

            targetTextField.topAnchor.constraint(equalTo: scribbleCanvas.bottomAnchor, constant: 40),
            targetTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            targetTextField.widthAnchor.constraint(equalToConstant: 300)
        ])
    }
}
</code></pre>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/1a90a8d9-dd7e-47b5-a6dc-e5a8e053e13b/image.gif" alt=""></p>
<p>이전에는 텍스트 필드 안에만 scribble을 적용할 수 있었다면, 이 예제를 통해서 다른 영역에 글씨를 쓰더라도 해당 텍스트 필드로 잘 입력되는 것을 볼 수 있었다.
<br>
<br></p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
</blockquote>
<p><a href="https://developer.apple.com/documentation/uikit/uiindirectscribbleinteraction-1nfjm">https://developer.apple.com/documentation/uikit/uiindirectscribbleinteraction-1nfjm</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Swift/UIKit] Handwriting recognition 필기체 인식 (1)]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUIUIKit-Handwriting-recognition-%ED%95%84%EA%B8%B0%EC%B2%B4-%EC%9D%B8%EC%8B%9D-1</link>
            <guid>https://velog.io/@sheep_jh/SwiftUIUIKit-Handwriting-recognition-%ED%95%84%EA%B8%B0%EC%B2%B4-%EC%9D%B8%EC%8B%9D-1</guid>
            <pubDate>Sun, 15 Feb 2026 03:11:22 GMT</pubDate>
            <description><![CDATA[<p>아이패드에서 손글씨(애플펜슬)로 쓴 글씨를 자동으로 텍스트로 변환시켜 주는 기능에 대해 궁금하여 <strong>Handwriting recognition</strong>에 대해 알아보려고 한다.</p>
<blockquote>
<h3 id="handwriting-recognition란">Handwriting recognition란?</h3>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/10df12d0-3e74-45e7-9e23-93b263b8eb1d/image.gif" alt="">직역하면 <strong>필기체 인식</strong>이다.</p>
<p>아이패드에서 애플펜슬로 텍스트필드에 글씨를 작성하면 자동으로 텍스트로 변환해서 입력이 되는데, 이것이 애플에서 지원하는 <strong>scribble</strong>기능이자 필기체 인식으로 볼 수 있다.</p>
<p><strong>Scribble</strong>기능은 <strong>텍스트 필드(직접)</strong>, <strong>커스텀 뷰(간접)</strong> 이렇게 크게 2곳에서 작동할 수 있다.</p>
<p><strong>UIScribbleInteraction(직접)</strong>, <strong>UIIndirectScribbleInteraction(간접)</strong> 이 각각 그걸 가능하게 해주는 클래스들이다.</p>
<p><code>UIScribbleInteraction</code> : 텍스트 필드 안에서 <strong>직접</strong> 손글씨를 써서 scribble기능이 적용되는 것
<code>UIIndirectScribbleInteraction</code> : 다른 커스텀 뷰에서 손글씨를 써서 그게 텍스트 필드 안으로 scribble되는 <strong>간접</strong>적인 것</p>
<p>이번글에서는 <strong>UIScribbleInteraction</strong>에 대해 알아보겠다.</p>
<blockquote>
<h3 id="uiscribbleinteraction직접">UIScribbleInteraction(직접)</h3>
</blockquote>
<p><code>역할</code></p>
<p><strong>1. Scribble 제어</strong> : 사용자가 Apple Pencil로 텍스트 입력창 위에 직접 글씨를 써서 텍스트를 입력하는 &#39;Scribble&#39; 동작을 관리한다.</p>
<p><strong>2. 커스터마이징</strong>: 기본적으로 UITextInput을 구현하는 모든 뷰에서 스크리블이 작동하지만, UIScribbleInteraction을 사용하면 특정 위치에서 기능을 끄거나 동작 방식을 세밀하게 조정할 수 있다.</p>
<br>

<p><code>주요 기능</code></p>
<p><strong>1. 특정 영역 비활성화</strong> : 특정 영역에서 Apple Pencil 입력을 그리기(Drawing) 용도로만 사용해야 할 경우 스크리블을 억제할 수 있다.</p>
<p><strong>2. UI 최적화</strong> : 사용자가 쓰기를 시작할 때 커스텀 플레이스홀더를 숨기거나, 입력창이 이동하는 동안 포커스가 잡히는 것을 지연시킬 수 있다.</p>
<p><strong>3. 필기 감지</strong> : 사용자가 현재 글씨를 쓰고 있는지(isHandlingWriting), 아니면 곧 Apple Pencil 입력을 할 것으로 예상되는지(isPencilInputExpected)를 확인할 수 있다.</p>
<br>

<p><code>주요 델리게이트 메서드 (UIScribbleInteractionDelegate)</code></p>
<p><strong>1. 스크리블 상호작용 및 제어</strong></p>
<pre><code class="language-swift">func scribbleInteraction(UIScribbleInteraction, shouldBeginAt: CGPoint) -&gt; Bool
func scribbleInteractionShouldDelayFocus(UIScribbleInteraction) -&gt; Bool</code></pre>
<ul>
<li>특정 좌표에서 스크리블을 시작할지 여부를 결정한다. (false 반환 시 해당 위치 스크리블 불가)</li>
<li>텍스트 입력 뷰에 포커스가 맞춰지는 것을 지연시키도록 지시한다.</li>
</ul>
<p><strong>2. 스크리블 입력 추적</strong></p>
<pre><code class="language-swift">func scribbleInteractionWillBeginWriting(UIScribbleInteraction)
func scribbleInteractionDidFinishWriting(UIScribbleInteraction)</code></pre>
<ul>
<li>사용자가 뷰에 입력을 시작하면 델리게이트에게 알린다.</li>
<li>Scribble이 마지막 단어를 받아쓰기하고 입력한 후 사용자가 뷰에서 쓰기를 멈췄음을 알린다.</li>
</ul>
<blockquote>
<h3 id="코드예제">코드예제</h3>
</blockquote>
<pre><code class="language-swift">import UIKit

class ScribbleViewController: UIViewController {

    let textView = UITextView()
    let customPlaceholderLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupScribbleInteraction()
    }

    private func setupUI() {
        view.backgroundColor = .white

        // 텍스트 뷰 설정
        textView.frame = CGRect(x: 20, y: 100, width: view.bounds.width - 40, height: 300)
        textView.layer.borderColor = UIColor.systemGray4.cgColor
        textView.layer.borderWidth = 1
        textView.font = .systemFont(ofSize: 18)
        view.addSubview(textView)

        // 커스텀 플레이스홀더 설정
        customPlaceholderLabel.text = &quot;여기에 Apple Pencil로 글씨를 써보세요.&quot;
        customPlaceholderLabel.textColor = .lightGray
        customPlaceholderLabel.frame = CGRect(x: 25, y: 108, width: 300, height: 20)
        view.addSubview(customPlaceholderLabel)
    }

    // MARK: - 1. Scribble 제어 및 커스터마이징 설정
    private func setupScribbleInteraction() {
        // 특정 위치 기능 제어 및 동작 방식 세밀 조정을 위해 Interaction 추가
        let scribbleInteraction = UIScribbleInteraction(delegate: self)
        textView.addInteraction(scribbleInteraction)
    }
}

// MARK: - UIScribbleInteractionDelegate
extension ScribbleViewController: UIScribbleInteractionDelegate {

    // 1. 특정 영역 비활성화 (스크리블 상호작용 제어)
    func scribbleInteraction(_ interaction: UIScribbleInteraction, shouldBeginAt location: CGPoint) -&gt; Bool {
        // 예: 텍스트 뷰 상단 50pt 영역은 &#39;그리기(Drawing)&#39; 전용 영역으로 간주하여 Scribble 비활성화
        if location.y &lt; 50 {
            print(&quot;상단 50pt 영역입니다. 스크리블이 금지됩니다&quot;)
            return false // false 반환 시 해당 위치 스크리블 불가
        }
        return true
    }

    // 2. 포커스 지연 (UI 최적화)
    func scribbleInteractionShouldDelayFocus(_ interaction: UIScribbleInteraction) -&gt; Bool {
        // 사용자가 필기를 시작하려고 할 때 텍스트 입력창에 즉각적인 포커스(키보드 올라옴 등)가
        // 잡히는 것을 지연시켜 더 자연스러운 필기 경험을 제공합니다.
        return true
    }

    // 3. 쓰기 시작 추적 (필기 감지)
    func scribbleInteractionWillBeginWriting(_ interaction: UIScribbleInteraction) {
        // 사용자가 쓰기를 시작할 때 커스텀 플레이스홀더를 숨김
        customPlaceholderLabel.isHidden = true

        // 현재 상태 확인 (필기 감지)
        print(&quot;--- 쓰기 시작 ---&quot;)
        print(&quot;Apple Pencil 입력 예상됨 (isPencilInputExpected): \(UIScribbleInteraction.isPencilInputExpected)&quot;)
        print(&quot;현재 글씨를 쓰는 중인가 (isHandlingWriting): \(interaction.isHandlingWriting)&quot;)
    }

    // 4. 쓰기 종료 추적 (필기 감지)
    func scribbleInteractionDidFinishWriting(_ interaction: UIScribbleInteraction) {
        // Scribble이 마지막 단어를 받아쓰기하고 입력을 마쳤을 때 호출됨
        print(&quot;--- 쓰기 종료 ---&quot;)

        // 텍스트가 비어있다면 플레이스홀더를 다시 표시
        if textView.text.isEmpty {
            customPlaceholderLabel.isHidden = false
        }
    }
}
</code></pre>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/aa0df19f-7d4b-4fdc-a9b9-898f5c017869/image.gif" alt=""></p>
<p>영상과 함께보면,</p>
<ul>
<li>애플펜슬로 쓰기를 시작할때 필기를 감지하여 플레이스 홀더를 숨겨주며 로그들을 찍는 모습을 볼 수있다. (3. scribbleInteractionWillBeginWriting) </li>
<li>쓰기를 멈췄을때는 또 쓰기 종료 로그가 찍힌다. 
(4. scribbleInteractionDidFinishWriting)</li>
<li>또한 텍스트 필드 상단 부분에서 스크리블을 시도하면 금지 로그가 뜨는 것을 볼 수있다. (1. scribbleInteraction)</li>
<li>그리고 포커스를 지연시켜주는 기능까지 구현되있다. 
(2. scribbleInteractionShouldDelayFocus)</li>
</ul>
<p><br><br>
다음글에서는 <strong>UIIndirectScribbleInteraction</strong>에 대해 알아보겠다.
<br></p>
<blockquote>
<h3 id="🍎-참고">🍎 참고</h3>
</blockquote>
<p><a href="https://developer.apple.com/documentation/uikit/uiscribbleinteraction">https://developer.apple.com/documentation/uikit/uiscribbleinteraction</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI/UIKit] UIViewControllerRepresentable 알아보기 + 쇼츠처럼 페이지뷰 만들기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUIUIKit-UIViewControllerRepresentable-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0-%EC%87%BC%EC%B8%A0%EC%B2%98%EB%9F%BC-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%B7%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUIUIKit-UIViewControllerRepresentable-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0-%EC%87%BC%EC%B8%A0%EC%B2%98%EB%9F%BC-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%B7%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sat, 23 Aug 2025 05:18:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h2 id="uiviewcontrollerrepresentable">UIViewControllerRepresentable</h2>
</blockquote>
<p><strong>UIViewControllerRepresentable</strong>는 UIViewController를 SwiftUI 에서 사용가능하게 해주는 프로토콜이다.</p>
<p>이 프로토콜을 채택하게 되면</p>
<pre><code class="language-swift">func makeUIViewController(context: Self.Context) -&gt; Self.UIViewControllerType

func updateUIViewController(Self.UIViewControllerType, context: Self.Context)</code></pre>
<p>이 두 메서드를 필수적으로 구현 해야 한다.</p>
<ul>
<li><p><strong>makeUIViewController(context:)</strong> 메서드는 UIKit 뷰 컨트롤러를 생성하고 초기화하는 역할을 한다. SwiftUI가 화면에 뷰를 처음 표시할 때 단 한 번만 호출되며, 이 메서드는 뷰 컨트롤러를 생성하고 반환하는 책임만을 가진다.</p>
</li>
<li><p><strong>updateUIViewController(_:context:)</strong> 메서드는  SwiftUI의 상태가 변경될 때마다 뷰 컨트롤러의 상태를 업데이트하는 역할을 한다. SwiftUI 뷰의 @State나 @Binding과 같은 프로퍼티가 변경되면, SwiftUI는 이 메서드를 호출해 UIKit 뷰 컨트롤러에 최신 데이터를 전달할 수 있도록 해준다.</p>
</li>
</ul>
<p>여기서 추가적으로 <strong>Coordinator</strong> 라는 개념이 있는데 Coordinator는 SwiftUI 뷰와 연동되는 UIKit 뷰 컨트롤러의 <strong>대리자(delegate)</strong> 역할을 하는 클래스다. 한 마디로, SwiftUI와 UIKit 사이의 소통을 원활하게 만들어주는 다리라고 할 수 있다 !</p>
<p>Coordinator 를 사용하면, Cocoa에서 자주 쓰이는 패턴들을 구현할 수 있다. 예를 들어, <strong>delegate</strong> 패턴, <strong>data source *<em>패턴, 그리고 *</em>target-action</strong>을 통한 사용자 이벤트 처리 같은 것들이 있다.</p>
<blockquote>
<h2 id="쇼츠-형태의-페이지-뷰-만들기">쇼츠 형태의 페이지 뷰 만들기</h2>
</blockquote>
<p>파일 구성은 이렇게 해주었다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/5698bfbe-7e91-4d5f-b76b-fe4e6d7fe253/image.png" alt=""></p>
<blockquote>
<h4 id="pageviewcontroller">PageViewController</h4>
</blockquote>
<pre><code class="language-swift">import SwiftUI
import UIKit


struct PageViewController&lt;Page: View&gt;: UIViewControllerRepresentable {
    var pages: [Page]
    @Binding var currentPage: Int

    func makeCoordinator() -&gt; Coordinator {
        Coordinator(self)
    }

    func makeUIViewController(context: Context) -&gt; UIPageViewController {
        let pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .vertical)
        pageViewController.dataSource = context.coordinator
        pageViewController.delegate = context.coordinator

        return pageViewController
    }

    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        pageViewController.setViewControllers(
            [context.coordinator.controllers[currentPage]], direction: .forward, animated: true)
    }

    class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
        var parent: PageViewController
        var controllers = [UIViewController]()

        init(_ pageViewController: PageViewController) {
            parent = pageViewController
            controllers = parent.pages.map { UIHostingController(rootView: $0) }
        }

        func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerBefore viewController: UIViewController) -&gt; UIViewController?
        {
            guard let index = controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index == 0 {
                return controllers.last
            }
            return controllers[index - 1]
        }


        func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerAfter viewController: UIViewController) -&gt; UIViewController?
        {
            guard let index = controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index + 1 == controllers.count {
                return controllers.first
            }
            return controllers[index + 1]
        }

        func pageViewController(
            _ pageViewController: UIPageViewController,
            didFinishAnimating finished: Bool,
            previousViewControllers: [UIViewController],
            transitionCompleted completed: Bool) {
                if completed,
                   let visibleViewController = pageViewController.viewControllers?.first,
                   let index = controllers.firstIndex(of: visibleViewController) {
                    parent.currentPage = index
                }
            }
    }
}
</code></pre>
<hr>
<pre><code class="language-swift">func makeCoordinator()</code></pre>
<p>이 메서드에서는 <strong>Coordinator</strong> 인스턴스를 생성하고 반환한다. Coordinator는 UIPageViewController의** 델리게이트*<em>와 *</em>데이터 소스 **역할을 하며, UIKit에서 발생한 이벤트를 SwiftUI로 전달하는 중요한 역할을 한다.</p>
<hr>
<pre><code class="language-swift">makeUIViewController(context:)</code></pre>
<p>이 메서드는 <strong>UIPageViewController 인스턴스</strong>를 처음 생성하고 초기 설정하는 역할을 한다. SwiftUI가 이 뷰를 화면에 처음 띄울 때 단 한 번만 호출된다.</p>
<p><code>transitionStyle: .scroll</code>: 페이지 넘김 애니메이션 스타일을 스크롤로 설정한다.
<code>navigationOrientation: .vertical</code>: 페이지 넘김 방향을 수직으로 설정한다.
<code>pageViewController.dataSource = context.coordinator</code>: UIPageViewController의 <strong>데이터 소스</strong>를 위에서 만든 Coordinator 인스턴스로 지정한다. 데이터 소스는 뷰 컨트롤러에 어떤 뷰를 표시할지 알려주는 역할을 한다.
<code>pageViewController.delegate = context.coordinator</code>: UIPageViewController의 <strong>델리게이트</strong>를 Coordinator 인스턴스로 지정한다. 델리게이트는 페이지 넘김이 완료되었을 때와 같은 이벤트를 처리한다.</p>
<hr>
<pre><code class="language-swift">updateUIViewController(_:context:)</code></pre>
<p>이 메서드는 SwiftUI의 <strong>currentPage</strong> 값이 변경될 때마다 호출되어, UIPageViewController의 현재 표시 페이지를 업데이트 한다.</p>
<p><code>pageViewController.setViewControllers(...)</code>: UIPageViewController에게 <strong>특정 뷰 컨트롤러를 표시</strong>하도록 지시한다. 여기서는 context.coordinator.controllers 배열에서 currentPage에 해당하는 뷰 컨트롤러를 가져와 보여준다.</p>
<hr>
<h3 id="coordinator-클래스">Coordinator 클래스</h3>
<p>-&gt; UIPageViewController의 <strong>델리게이트</strong>와 *<em>데이터 소스 *</em>역할을 수행하는 핵심 클래스다.</p>
<p><code>var parent: PageViewController</code>: 부모인 PageViewController 구조체에 대한 참조를 가진다. 이를 통해 @Binding으로 선언된 currentPage와 같은 프로퍼티에 접근할 수 있다.</p>
<p><code>var controllers = [UIViewController]()</code>: parent.pages 배열의 각 SwiftUI 뷰를 <strong>UIHostingController</strong>를 사용해 UIViewController로 변환하여 저장한다. UIHostingController는 SwiftUI 뷰를 UIKit 환경에서 사용할 수 있게 해주는 래퍼 역할을 한다.</p>
<hr>
<pre><code class="language-swift">func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerBefore viewController: UIViewController)</code></pre>
<p>현재 페이지의 <strong>이전 페이지</strong>에 해당하는 UIViewController를 반환한다. 이 메서드를 통해 이전 페이지로 스와이프할 때 어떤 뷰를 보여줄지 결정한다. 페이지가 첫 번째일 경우 마지막 페이지를 반환하여 무한 순환 스크롤을 구현한다.</p>
<hr>
<pre><code class="language-swift"> func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerAfter viewController: UIViewController)</code></pre>
<p>현재 페이지의 <strong>다음 페이지</strong>에 해당하는 UIViewController를 반환한다. 마찬가지로 마지막 페이지일 경우 첫 번째 페이지를 반환한다.</p>
<hr>
<pre><code class="language-swift"> func pageViewController(
            _ pageViewController: UIPageViewController,
            didFinishAnimating finished: Bool,
            previousViewControllers: [UIViewController],
            transitionCompleted completed: Bool)</code></pre>
<p>페이지 넘김 애니메이션이 완료되었을 때 호출되는 <strong>델리게이트 메서드</strong>다.</p>
<p><code>if completed, ...</code>: 애니메이션이 성공적으로 완료되었을 때만 실행된다.
<code>let visibleViewController = pageViewController.viewControllers?.first</code>: 현재 화면에 보이는 뷰 컨트롤러를 가져온다.
<code>let index = controllers.firstIndex(of: visibleViewController)</code>: 보이는 뷰 컨트롤러의 인덱스를 찾는다.
<code>parent.currentPage = index</code>: 찾은 인덱스를 부모 뷰의 @Binding 변수인 currentPage에 할당한다. 이 부분이 바로 <strong>UIKit에서 SwiftUI로 데이터가 다시 전달되는 핵심적인 과정</strong>이다. </p>
<blockquote>
<h4 id="pageview">PageView</h4>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct PageView&lt;Page: View&gt;: View {
    var pages: [Page]
    @State private var currentPage = 0

    var body: some View {
        PageViewController(pages: pages, currentPage: $currentPage)
    }
}</code></pre>
<blockquote>
<h4 id="pageviewcontrollerapp">PageViewControllerApp</h4>
</blockquote>
<pre><code class="language-swift">import SwiftUI

@main
struct PageViewControllerApp: App {
    var body: some Scene {
        WindowGroup {
            PageView(pages: [
                Color.green,
                Color.orange,
                Color.purple
            ])
        }
    }
}
</code></pre>
<blockquote>
<h3 id="실제-구현-영상">실제 구현 영상</h3>
</blockquote>
 <p align="center"><img src="https://velog.velcdn.com/images/sheep_jh/post/966c010e-c084-4d63-bffc-ade86ad6cb39/image.gif" width = 40% height = 40%><p>

<hr>
<h3 id="🍎참고">🍎참고</h3>
<p><a href="https://developer.apple.com/documentation/swiftui/uiviewcontrollerrepresentable">https://developer.apple.com/documentation/swiftui/uiviewcontrollerrepresentable</a>
<a href="https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit">https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] FCM으로 Remote Notification 보내기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-FCM%EC%9C%BC%EB%A1%9C-Remote-Notification-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-FCM%EC%9C%BC%EB%A1%9C-Remote-Notification-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Tue, 19 Aug 2025 08:27:07 GMT</pubDate>
            <description><![CDATA[<p>저번글에서 Local Notification을 보냈었는데 이번에는 FCM으로 원격 푸시알림을 보내보려고 한다 !</p>
<blockquote>
<h2 id="remote-notification">Remote Notification</h2>
</blockquote>
<p>원격 알림은 앱이 실행 중이 아닐때도 앱을 사용하는 기기에 소량의 데이터를 푸시할 수 있다.</p>
<p>원격 알림 전달에는 다음과 같은 핵심 구성 요소가 포함된다</p>
<ul>
<li>내 회사의 서버 (provider 서버라고 불림)</li>
<li>Apple Push Notification service (APNs)</li>
<li>유저의 디바이스</li>
<li>사용자 기기에서 실행되는 앱</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/a182456b-6327-40e3-8536-4befed38d52e/image.png" alt=""></p>
<p>원격알림은 <strong>내 회사 서버</strong>에서 시작된다 (혹은 FCM과 같은 서드파티) 개발자는 유저에게 보내고 싶은 알림, 보내고 싶은 시간을 결정할 수 있다. 
알림을 보낼 시점이 되면 알림 데이터와 사용자 기기의 고유 식별자가 포함된 요청을 생성한다. </p>
<p>그런 다음 <strong>APNs</strong>로 요청을 전달하면 APNs에서 사용자 기기로 알림을 전송한다</p>
<p>알림을 수신하면 <strong>사용자 기기</strong>의 운영 체제가 모든 사용자 상호작용을 처리하고 <strong>앱으로 알림을 전송</strong>한다</p>
<p>바로 실습으로 알아보겠다.</p>
<blockquote>
<h2 id="1-apns에-앱등록">1. APNs에 앱등록</h2>
</blockquote>
<p>Apple 푸시 알림 서비스(APNs)는 사용자 기기에 알림을 전송하기 전에 해당 기기의 주소를 알아야 한다. 이 주소는 기기와 앱 모두에 <strong>고유한 디바이스 토큰 형태</strong>로 제공된다. 앱 실행 시, 앱은 APNs와 통신하여 디바이스 토큰을 수신하고, 사용자는 이 토큰을 provider 서버로 전달한다. 서버는 전송하는 모든 알림에 이 토큰을 포함한다.</p>
<hr>
<p>먼저 Xcode에 들어가서 project -&gt; targets -&gt; Signing &amp; Capabilities 에 진입한뒤 +Capability 버튼을 눌러 <strong>Background Modes</strong>, <strong>Remote notifications</strong>를 추가해준다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/5a2c95b9-66cd-43d7-a8ba-a3a166dd4993/image.png" alt=""></p>
<hr>
<p>그 후, 애플 개발자 계정에 들어가서 Certificates, Identifiers &amp; Profiles을 찾은 뒤 APNs를 선택해주고 <strong>Key</strong>를 발급받는다 (p8파일이다)</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/071ef6ae-460b-495b-b996-a7ba4eafffba/image.png" alt=""><img src="https://velog.velcdn.com/images/sheep_jh/post/af16dd0e-1898-4b03-8ad2-d4356e0424c1/image.png" alt=""></p>
<blockquote>
<h2 id="2-fcm-설정">2. FCM 설정</h2>
</blockquote>
<p>Firebase 사이트에서 새 프로젝트를 만들고 새로운 앱 등록을 한다.</p>
<img src=https://velog.velcdn.com/images/sheep_jh/post/c49f4b25-efa8-4f2d-a140-2a9848ba74ea/image.png width=50% height=50%> 

<hr>
<p><strong>GoogleService-Info.plist</strong> 파일을 다운받고 Xcode에 넣어준다.
<img src="https://velog.velcdn.com/images/sheep_jh/post/dfa2e1f1-00af-4e13-8096-e5a86e8071d6/image.png" alt=""></p>
<hr>
<p>SPM으로 <strong>FirebaseAnalytics</strong>와 <strong>FirebaseMessaging</strong>을 추가해준다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/a2d7254c-23ca-4e68-81dc-3417d3ea51a1/image.png" alt=""></p>
<hr>
<p>그후, 다시 Firebase 사이트에 와서 프로젝트 설정 -&gt; <strong>클라우드 메시징</strong>에 들어간 후 Apple 앱 구성에서 아까 다운받은 <strong>APNs p8파일</strong>을 넣어주고, <strong>키 ID</strong>와 <strong>팀 ID</strong>도 넣어준다.</p>
<img src=https://velog.velcdn.com/images/sheep_jh/post/86eb54ae-e3a5-4ce9-931b-890a7277d973/image.png width=50% height=50%> 

<hr>
<blockquote>
<h2 id="3-appdelegate-설정">3. AppDelegate 설정</h2>
</blockquote>
<pre><code class="language-swift">import UIKit
import FirebaseCore
import FirebaseMessaging


class AppDelegate: NSObject, UIApplicationDelegate {
    //앱 실행될 때 메서드
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -&gt; Bool {
        //&lt;---🔥 FireBase ---&gt;
        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        //&lt;------------------&gt;

        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .badge, .sound],
            completionHandler: { _, _ in }
        )

        application.registerForRemoteNotifications()

        return true
    }

    //APNs 서버로부터 디바이스 토큰을 성공적으로 발급받았을 때 호출
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
    }

    //APNs 토큰 발급에 실패했을 때 호출
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print(error)
    }

}

//MARK: - 푸시 알림 관련 처리
extension AppDelegate: UNUserNotificationCenterDelegate {
    // Foreground에서 알림이 도착했을때 호출
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -&gt; Void) {
        print(&quot;foreground에서 알림 도착&quot;)
        completionHandler([.badge, .banner, .list, .sound])
    }

    // 유저가 푸시 알림을 탭했을때 처리
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -&gt; Void) {
        print(&quot;유저가 푸시 알람을 탭함&quot;)
        completionHandler()
    }
}

//MARK: - FCM 토큰 관련 처리
extension AppDelegate: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        print(&quot;🔥FCM Token: \(String(describing: fcmToken))&quot;)

        let dataDict: [String: String] = [&quot;token&quot;: fcmToken ?? &quot;&quot;]
        NotificationCenter.default.post(
            name: Notification.Name(&quot;FCMToken&quot;),
            object: nil,
            userInfo: dataDict
        )
        // TODO: 우리 서버로 FCM Token 보내주기
    }
}</code></pre>
<hr>
<p>하나 하나 살펴보겠다.</p>
<pre><code class="language-swift">func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -&gt; Bool {
        //&lt;---🔥 FireBase ---&gt;
        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        //&lt;------------------&gt;

        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .badge, .sound],
            completionHandler: { _, _ in }
        )

        application.registerForRemoteNotifications()

        return true
    }</code></pre>
<p>제일 처음 앱이 실행될 때 메서드다. 
먼저 Firebase SDK를 초기화 해주고, <strong>MessagingDelegate</strong>와 <strong>UNUserNotificationCenterDelegate</strong>를 각각 연결 시켜준다.</p>
<p>그 후 <strong>UNUserNotificationCenter.current().requestAuthorization()</strong> 를 통해 푸시 알림 권한부터 요청한 후  <strong>application.registerForRemoteNotifications()</strong> 이 메서드로 APNs에 푸시 알림을 등록 요청한다.</p>
<hr>
<pre><code class="language-swift">  //APNs 서버로부터 디바이스 토큰을 성공적으로 발급받았을 때 호출
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
    }

    //APNs 토큰 발급에 실패했을 때 호출
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print(error)
    }</code></pre>
<p>다음은 이 2가지 메서든데, 위 메서드는 APNs 서버로부터 <strong>디바이스 토큰을 성공적으로 발급</strong>받았을 때 호출되며, 밑에 메서드는 디바이스 토큰 발급에 실패했을 때 호출된다. </p>
<p><strong>Messaging.messaging().apnsToken = deviceToken</strong> 이 코드 같은 경우에 APNs 토큰을 명시적으로 FCM 등록 토큰에 매핑 해주는 역할을 한다.</p>
<p>하지만 여기서 이 메서드가 호출되지않고 <strong>swizzle</strong>과 관련된 로그가 뜨는 경우가 있는데, firebase가 메서드 동적 교체를 지원하기 때문에 런타임에서 firebase가 메서드를 가로채서 자동으로 APNs토큰을 등록하고 Firebase 서버로 전송하기 때문에 <strong>didRegisterForRemoteNotificationsWithDeviceToken</strong> 이 친구가 호출되지 않는 것이다. </p>
<p>파이어베이스가 swizzling을 해주길 원하지 않는다면 Info.plist에 <strong>FirebaseAppDelegateProxyEnabled을</strong> 추가하고 NO로 두면 더이상 자동으로 메서드를 가로채지 않는다.
<img src="https://velog.velcdn.com/images/sheep_jh/post/e2be9f01-9a14-4ba5-8dd4-e1f6270ec966/image.png" alt=""></p>
<hr>
<pre><code class="language-swift">//MARK: - 푸시 알림 관련 처리
extension AppDelegate: UNUserNotificationCenterDelegate {
    // Foreground에서 알림이 도착했을때 호출
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -&gt; Void) {
        print(&quot;foreground에서 알림 도착&quot;)
        completionHandler([.badge, .banner, .list, .sound])
    }

    // 유저가 푸시 알림을 탭했을때 처리
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -&gt; Void) {
        print(&quot;유저가 푸시 알람을 탭함&quot;)
        completionHandler()
    }
}
</code></pre>
<p>다음은 푸시 알림 관련 처리에 관한 메서드들이다.</p>
<p>위 메서드는 <strong>foreground</strong>에서 알림이 도착했을때 호출된다.
밑에 메서드는 유저가 foreground/background 관계없이 <strong>푸시알림을 탭했을때</strong> 호출된다.</p>
<p>그래서 이 메서드에서는 유저가 푸시 알림을 탭했을 때 <strong>딥링크</strong>등의 처리를 해줄 수 있다.</p>
<hr>
<pre><code class="language-swift">//MARK: - FCM 토큰 관련 처리
extension AppDelegate: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        print(&quot;🔥FCM Token: \(String(describing: fcmToken))&quot;)

        let dataDict: [String: String] = [&quot;token&quot;: fcmToken ?? &quot;&quot;]
        NotificationCenter.default.post(
            name: Notification.Name(&quot;FCMToken&quot;),
            object: nil,
            userInfo: dataDict
        )
        // TODO: 우리 서버로 FCM Token 보내주기
    }
}
</code></pre>
<p>다음은 FCM 토큰 관련 처리 메서드다. 이 <strong>MessagingDelegate</strong> 딜리게이트를 통해 FCM 토큰이 업데이트 될때마다 메서드가 호출될 수 있다. </p>
<p>참고로 앱을 처음 설치했을때는 FCM토큰이 <strong>파이어베이스가 초기화</strong>될때 한 번, <strong>registerForRemoteNotifications()</strong> 메서드가 실행되고 한 번, 이렇게 총 2번 발급받게 되는 이슈가 있다. 앱을 한 번 설치하고 나면 그 후로는 한 번만 발급되긴 한다.</p>
<p>어쨌든 이 딜리게이트를 통해 FCM 토큰을 받으면 이 토큰을 우리 서버에 전달해주는 로직을 짤 수 있고, 우리 서버가 토큰을 받으면 이 토큰으로 푸시 알림을 쏠 수 있는 것이다.</p>
<hr>
<blockquote>
<h2 id="4-실제-푸시-알림-테스트">4. 실제 푸시 알림 테스트</h2>
</blockquote>
<p>푸시 알림 테스트를 위해 Firebase 사이트에서 Cloud Messaging에 들어간 후 캠페인 만들기를 눌러준다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/e85a13e8-8337-4721-b7d3-5ba66f62093b/image.png" alt=""></p>
<p>이런 창이 뜨면 원하는 제목과 텍스트를 입력해주고, <strong>테스트 메시지 전송 버튼</strong>을 눌러준다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/7aabf313-d4c0-4be4-92b0-c3abe165026b/image.png" alt=""></p>
<p>그럼 이런 창이 뜰텐데 앱을 빌드하면 FCM 토큰이 출력될건데 그 토큰을 여기다 넣어주고 테스트 버튼을 눌러준다.</p>
 <p align="center"><img src="https://velog.velcdn.com/images/sheep_jh/post/cc888659-cde3-4f74-a16d-c6f7513f56f6/image.gif" width = 40% height = 40%><p>
---
### 🍎 참고

<p><a href="https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server">https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server</a>
<a href="https://firebase.google.com/docs/cloud-messaging/ios/client?hl=ko">https://firebase.google.com/docs/cloud-messaging/ios/client?hl=ko</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] User Notifications으로 Local 푸시알림 보내보기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-User-Notifications%EC%9C%BC%EB%A1%9C-Local-%ED%91%B8%EC%8B%9C%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-User-Notifications%EC%9C%BC%EB%A1%9C-Local-%ED%91%B8%EC%8B%9C%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Mon, 18 Aug 2025 04:54:42 GMT</pubDate>
            <description><![CDATA[<p>앱에서 푸시알림을 보내기 위해 <strong>Local</strong>과 <strong>Remote</strong>방식이 있다. Local은 앱 내에서 미리 푸시알림 보낼 컨텐츠를 설정하여 보내고 Remote는 원격으로 푸시알림을 보낼 수 있다. 이번 글에서는 Local로 푸시 알림 보내는 예제를 학습해 보겠다.</p>
<blockquote>
<h2 id="user-notifications">User Notifications</h2>
</blockquote>
<p>-&gt; <strong>User Notifications</strong>는 서버에서 사용자 기기로 푸시알람을 보내거나 앱에서 로컬로 알림을 생성할 수 있게 해주는 프레임워크다.</p>
<p>로컬 알림의 경우, 앱에서 <strong>알림 콘텐츠</strong>를 생성하고 시간이나 위치와 같이 알림 전송을 <strong>트리거</strong>하는 조건을 지정한다.</p>
<p>알림콘텐츠와 트리거가 당장 이해 안될 수 있지만 밑에 예제에서 다시 다뤄보겠다.</p>
<p>우선 전체 코드부터 보자.</p>
<blockquote>
<h2 id="전체-코드">전체 코드</h2>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct ContentView: View {
    @State private var selectedDate = Date()

    var body: some View {
        VStack {
            //설정된 시간으로 알림 보내기
            DatePicker(&quot;알림 시간&quot;, selection: $selectedDate, displayedComponents: .hourAndMinute)
                .datePickerStyle(.wheel)
                .labelsHidden()

            Button(&quot;설정한 시간에 알림 보내기&quot;) {
                Task { await scheduleNotification(at: selectedDate) }
            }

            //5초후 알림 보내기
            Button(&quot;5초후 알림 보내기&quot;) {
                Task { await scheduleNotification(after: 5) }
            }
        }
        .onAppear {
            Task { await requestNotiPermission() }
        }
    }

    //MARK: - 알림 권한 요청
    func requestNotiPermission() async {
        let center = UNUserNotificationCenter.current()

        do {
            try await center.requestAuthorization(options: [.alert, .sound, .badge])
        } catch {
            print(error)
        }
    }

    //MARK: - 설정한 시간에 맞춰 보내기
    func scheduleNotification(at date: Date) async {
        let notificationCenter = UNUserNotificationCenter.current()
        notificationCenter.removeAllPendingNotificationRequests()

        //&lt;---- 알람 보낼 콘텐츠 제작 ----&gt;
        let content = UNMutableNotificationContent()
        content.title = &quot;⏰ 시간 알람&quot;
        content.body = &quot;설정한 시간이 되었습니다!&quot;
        content.sound = UNNotificationSound(named: UNNotificationSoundName(&quot;notiSound.caf&quot;))
        //&lt;--------------------------&gt;

        //&lt;---- 트리거 설정 ----&gt;
        let triggerDate = Calendar.current.dateComponents([.hour, .minute], from: date)
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: triggerDate, repeats: true)
        //&lt;------------------&gt;

        //&lt;---- 알림 요청 생성 + 등록 ----&gt;
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
        do {
            try await notificationCenter.add(request)
        } catch {
            print(error)
        }
        //&lt;---------------------------&gt;
    }

    //MARK: - 특정 시간 후 보내기
    func scheduleNotification(after seconds: TimeInterval) async {
        let notificationCenter = UNUserNotificationCenter.current()
        notificationCenter.removeAllPendingNotificationRequests()

        //&lt;---- 알람 보낼 콘텐츠 제작 ----&gt;
        let content = UNMutableNotificationContent()
        content.title = &quot;⏱ 5초 뒤 알람&quot;
        content.body = &quot;방금 요청한 알람이 도착했습니다!&quot;
        content.sound = UNNotificationSound(named: UNNotificationSoundName(&quot;notiSound.caf&quot;))
        //&lt;--------------------------&gt;

        //&lt;---- 트리거 설정 ----&gt;
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: seconds, repeats: false)
        //&lt;------------------&gt;

        //&lt;---- 알림 요청 생성 + 등록 ----&gt;
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
        do {
            try await notificationCenter.add(request)
        } catch {
            print(error)
        }
        //&lt;---------------------------&gt;
    }
}</code></pre>
<p>이 코드에서는 크게 두 가지 기능이 있는데, 
<strong>1. 사용자가 설정한 시간에 알림을 보내는 기능
2. 일정시간 후 알림을 보내는 기능</strong>
이렇게 볼 수 있다.</p>
<p>우선, 이 기능들이 가능하게 하기 위해서는 알림 권한 요청이 필요하다.</p>
<hr>
<pre><code class="language-swift">//MARK: - 알림 권한 요청
    func requestNotiPermission() async {
        let center = UNUserNotificationCenter.current()

        do {
            try await center.requestAuthorization(options: [.alert, .sound, .badge])
        } catch {
            print(error)
        }
    }</code></pre>
<p>알림 권한을 유저가 허용해야만 푸시알림이 정상적으로 간다 !</p>
<p>이 메서드를 실행하게 되면 유저에게 알림 권한을 허용하라는 팝업이 뜬다.
여기서, <strong>UNUserNotificationCenter</strong> 객체를 자주 보게될 것인데 이 친구는 <strong>앱에서 알림 관련 활동을 중앙에서 관리해주는 친구</strong>로 보면 된다.</p>
<p>*<em>requestAuthorization(options: [.alert, .sound, .badge]) 
*</em>이 메서드를 통해 알림표시, 알림소리, 앱 아이콘에 숫자로 붙는 뱃지 등을 요청할 수 있다.</p>
<hr>
<pre><code class="language-swift">//MARK: - 설정한 시간에 맞춰 보내기
    func scheduleNotification(at date: Date) async {
        let notificationCenter = UNUserNotificationCenter.current()
        notificationCenter.removeAllPendingNotificationRequests()

        //&lt;---- 알람 보낼 콘텐츠 제작 ----&gt;
        let content = UNMutableNotificationContent()
        content.title = &quot;⏰ 시간 알람&quot;
        content.body = &quot;설정한 시간이 되었습니다!&quot;
        content.sound = UNNotificationSound(named: UNNotificationSoundName(&quot;notiSound.caf&quot;))
        //&lt;--------------------------&gt;

        //&lt;---- 트리거 설정 ----&gt;
        let triggerDate = Calendar.current.dateComponents([.hour, .minute], from: date)
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: triggerDate, repeats: true)
        //&lt;------------------&gt;

        //&lt;---- 알림 요청 생성 + 등록 ----&gt;
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
        do {
            try await notificationCenter.add(request)
        } catch {
            print(error)
        }
        //&lt;---------------------------&gt;
    }</code></pre>
<p>그 다음 보게될 메서드는 <strong>설정한 시간에 맞춰 알림을 보내는 기능</strong>이다.</p>
<p>여기서, 로컬에서 푸시알림을 보내기 위해서는 크게 3가지 흐름으로 볼 수 있는데</p>
<p><strong>1. 알림 콘텐츠 제작 **-&gt;</strong> 2. 알림 트리거 설정 <strong>-&gt;</strong> 3. 알림 요청 생성 + 등록**</p>
<p>이렇게 볼 수 있다.</p>
<ol>
<li>먼저 알림 콘텐츠 제작 같은 경우에는 <strong>UNMutableNotificationContent</strong> 클래스를 통해 제작할 수 있다. 푸시알림에 들어갈 title, body, sound 등을 설정할 수 있으며 sound 같은 경우에 기본 소리를 원한다면 <strong>.default</strong> 로 설정해주면 된다. 그런데 나는 소리를 커스텀하고 싶어서 <strong>UNNotificationSound</strong>를 통해 직접 설정을 해주었으며, 
<img src="https://velog.velcdn.com/images/sheep_jh/post/8070c34d-9687-4783-b2dc-373c084f39b8/image.png" alt="">무료 mp3파일을 다운받아 <strong>caf 형식</strong>으로 바꿔서 넣어줬다.
(mp3 형식은 지원을 안하기에 지원하는 형식들은 <a href="https://developer.apple.com/documentation/usernotifications/unnotificationsound">UNNotificationSound</a> 공식문서에서 확인할 수 있다)</li>
</ol>
<ol start="2">
<li>알림 트리거는 3가지가 있는데</li>
</ol>
<ul>
<li><p><strong>UNCalendarNotificationTrigger</strong> (설정한 시간 알림 트리거)</p>
</li>
<li><p><strong>UNTimeIntervalNotificationTrigger</strong> (특정 시간 간격 알림 트리거)</p>
</li>
<li><p><strong>UNLocationNotificationTrigger</strong> (위치에 따른 알림 트리거)
어떤 방아쇠를 선택하냐에 따라 알림을 쏘는 방식이 달라지는 것이다 ~
<br>여기 코드에서는 <strong>UNCalendarNotificationTrigger를</strong> 사용했고 </p>
<pre><code>      **dateMatching**에는 선택한 시간을 넣어주고 **repeats**을 true로 두면 설정한 시간마다 반복해서 알림을 쏘게 된다</code></pre><ol start="3">
<li>알림요청 생성 + 등록 같은 경우에는 </li>
</ol>
<p><strong>UNNotificationRequest</strong> 를 통해 우리가 만들어둔 <strong>알림콘텐츠, 트리거</strong>를 넣어 request를 만들어준다. 여기서 주의할 점은 identifier는 알림의 식별자 역할을 해주며 고유해야한다. <br>
그후, <strong>notificationCenter.add(request)</strong> 메서드가 실행되면 시스템은 요청과 관련된 트리거 조건을 추적하기 시작하며, 트리거 조건이 충족되면 알림이 전송된다 !</p>
</li>
</ul>
<p><strong>이렇게 로컬 푸시알림이 동작하게 된다.</strong></p>
<hr>
<pre><code class="language-swift"> //MARK: - 특정 시간 후 보내기
    func scheduleNotification(after seconds: TimeInterval) async {
        let notificationCenter = UNUserNotificationCenter.current()
        notificationCenter.removeAllPendingNotificationRequests()

        //&lt;---- 알람 보낼 콘텐츠 제작 ----&gt;
        let content = UNMutableNotificationContent()
        content.title = &quot;⏱ 5초 뒤 알람&quot;
        content.body = &quot;방금 요청한 알람이 도착했습니다!&quot;
        content.sound = UNNotificationSound(named: UNNotificationSoundName(&quot;notiSound.caf&quot;))
        //&lt;--------------------------&gt;

        //&lt;---- 트리거 설정 ----&gt;
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: seconds, repeats: false)
        //&lt;------------------&gt;

        //&lt;---- 알림 요청 생성 + 등록 ----&gt;
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
        do {
            try await notificationCenter.add(request)
        } catch {
            print(error)
        }
        //&lt;---------------------------&gt;
    }</code></pre>
<p>방금 로컬 푸시알림 과정에 대해 알았으니 이 코드도 쉽게 보일 것이다.</p>
<p>똑같이 알림보낼 콘텐츠를 제작, 트리거를 설정, 요청을 생성하고 등록하는 절차를 밟는다. </p>
<p>달라진건 <strong>UNTimeIntervalNotificationTrigger</strong> 트리거로 변경된 것 뿐이다.
이 트리거 같은 경우에, <strong>timeInterval</strong>에 원하는 시간을 넣어줄 수 있는데 만약에 60을 넣어주면 60초후 알림이 울리게 된다. </p>
<p>그리고 repeats을 true로 두면 알림이 반복하게 오는데, 60초를 넣으면 60초마다 푸시 알림이 오는 것이다. 
*<em>하지만 주의할 점은 !! repeats이 true일때, timeInterval을 최소 60초로 두지 않으면 에러가 난다. *</em> (아마 짧은 시간에 계속 반복적으로 알림을 쏘면 성능상 문제가 있어서 애플측에서 막아둔거 같다)</p>
<p><strong>그래도 repeats이 false면</strong> 5초를 넣어도, 1초를 넣어도 상관없다 ~</p>
<ul>
<li>아 그리고 <strong>removeAllPendingNotificationRequests()</strong>을 위에 코드도 그렇고 젤 위에 넣어준 이유는 이 친구가 이전에 있는 알림 request를 없애주는 역할을 하는데, 만약에 이 메서드를 실행하지 않으면 한 번 발사된 알림은 계속 반복되어 발사되는 현상을 보게 될 것이다.</li>
</ul>
<hr>
<blockquote>
<h2 id="실제-구현-영상">실제 구현 영상</h2>
</blockquote>
<p>*설정한 시간으로 알림보내기</p>
<p align="center"><img src="https://velog.velcdn.com/images/sheep_jh/post/062d564b-c755-4f34-a043-b2c0b366309b/image.gif" width="200"><p>


<p>*5초후 알림보내기</p>
  <p align="center"><img src="https://velog.velcdn.com/images/sheep_jh/post/5d0a54e2-ba2d-4c5f-b4e0-c4f48a9c9e70/image.gif" width="200"><p>

<p><strong>* 현재 foreground 에서는 알림이 오지 않는데 foreground 에서도 알림을 받을려면 AppDelegate설정을 해줘야 하는데 이 부분은 다음에 알아보겠습니다.</strong></p>
<hr>
<h2 id="🍎-참고"><strong>🍎 참고</strong></h2>
<p><a href="https://developer.apple.com/documentation/usernotifications">https://developer.apple.com/documentation/usernotifications</a>
<a href="https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications">https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications</a>
<a href="https://developer.apple.com/documentation/usernotifications/unusernotificationcenter">https://developer.apple.com/documentation/usernotifications/unusernotificationcenter</a>
<a href="https://developer.apple.com/documentation/usernotifications/scheduling-a-notification-locally-from-your-app">https://developer.apple.com/documentation/usernotifications/scheduling-a-notification-locally-from-your-app</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] AVFoundation을 통한 카메라 기능 만들기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-AVFoundation%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%B9%B4%EB%A9%94%EB%9D%BC-%EA%B8%B0%EB%8A%A5-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-AVFoundation%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%B9%B4%EB%A9%94%EB%9D%BC-%EA%B8%B0%EB%8A%A5-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Mon, 11 Aug 2025 13:59:39 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h2 id="avfoundation">AVFoundation</h2>
</blockquote>
<ul>
<li><p>AVFoundation은 사진, 동영상, 오디오 등을 재생/캡쳐/처리 하는데 있어 만능 도구라 볼 수 있다.</p>
</li>
<li><p>그 중 카메라로 캡쳐하는 기능을 만들거라 AVFoundation Capture subsystem을 사용해보겠다.</p>
</li>
</ul>
<blockquote>
<h2 id="capture-architecture">Capture Architecture</h2>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/39b8d55c-9ac8-4299-8995-a3f249394213/image.png" alt=""></p>
<p>캡쳐 아키텍처에 대한 이해가 있으면 좋기 때문에 간단히 설명을 해보자면,
캡쳐 아키텍처에서 중요한 부분은 <strong>Session</strong>, <strong>Input</strong>, <strong>Output</strong>이다.<br>
<strong>AVCaptureSession</strong> : 하나 이상의 Input을 하나 이상의 output과 연결한다.
<strong>AVCaptureDeviceInput</strong> : iOS 기기나 Mac에 내장된 카메라/마이크와 같은 캡처 장치를 포함한 미디어 소스다.
<strong>AVCaptureOutput</strong> : Input에서 미디어를 수집하여 디스크에 기록된 동영상 파일이나 라이브 처리에 사용할 수 있는 원시 픽셀 버퍼와 같은 유용한 데이터를 생성한다.</p>
<p>비유하자면, Session은 빨대이며 Input과 Output은 각각의 구멍이다.</p>
<blockquote>
<h2 id="카메라-만들기">카메라 만들기</h2>
</blockquote>
<p>우선 폴더 형태는 CameraFeature + ContentView로 이루어져 있다.</p>
<p><img src="https://velog.velcdn.com/images/sheep_jh/post/10194d84-9cd6-47c9-9e79-4424a3ae6ea0/image.png" alt=""></p>
<blockquote>
<h3 id="infoplist-설정">Info.plist 설정</h3>
</blockquote>
<p>TARGETS -&gt; Info 에 들어가서 <strong>Privacy - Camera Usage Description</strong> 을 추가해주고 카메라 권한 허용하라는 내용을 적어준다.
<img src="https://velog.velcdn.com/images/sheep_jh/post/3a8be54f-6221-478e-b4ba-a13435abc557/image.png" alt=""></p>
<blockquote>
<h3 id="contentview">ContentView</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct ContentView: View {
    @State var isPresented: Bool = false

    var body: some View {
        VStack {
            Button(&quot;카메라 열기&quot;) {
                isPresented = true
            }
        }
        .sheet(isPresented: $isPresented) {
            CameraView()
        }
    }
}</code></pre>
<p>우선 ContentView에서는 sheet를 통해 카메라 뷰를 띄울거라  아주 간단하게 작성해봤다.</p>
<blockquote>
<h3 id="cameraview">CameraView</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct CameraView: View {
    @StateObject private var viewModel: CameraViewModel = CameraViewModel()
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        ZStack {
            //A : 캡쳐된 이미지 (사진 찍혔을때)
            if let image = viewModel.capturedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                //다시 찍기 버튼
                    .overlay(alignment: .bottom) {
                        Button(&quot;다시 찍기&quot;) {
                            viewModel.retake()
                        }
                    }
            }
            //B : 카메라 프리뷰 (카메라 렌즈로 보이는 뷰)
            else {
                CameraPreviewView(session: viewModel.captureSession)
                //카메라 전환 버튼
                    .overlay(alignment: .bottomLeading) {
                        Button(&quot;카메라 전환&quot;) {
                            viewModel.switchCamera()
                        }
                    }
                //사진 촬영 버튼
                    .overlay(alignment: .bottom) {
                        Button(&quot;사진 촬영&quot;) {
                            viewModel.takePhoto()
                        }
                    }
            }
        }
        //카메라 권한 없을때 알럿
            .alert(&quot;카메라 권한이 필요해요&quot;, isPresented: $viewModel.isAlertPresented) {
                Button(&quot;취소&quot;) { dismiss() }
                Button(&quot;설정으로 이동&quot;) {
                    viewModel.goSetting()
                    dismiss()
                }
            }
            .onAppear{
                Task { await viewModel.checkCameraAuth() }
            }
    }
}
</code></pre>
<p>카메라가 띄워질 뷰다. <strong>카메라로 찍힌 사진</strong>을 보여줄 A뷰와 <strong>카메라 렌즈로 실제 세상을 보여줄</strong> B뷰로 나누었다.</p>
<p>그리고 사진을 찍었어도 <strong>다시 찍기</strong> 위한 버튼을 A뷰에 뒀고, <strong>카메라 전환과 사진 촬영</strong>을 위한 버튼을 B뷰에 뒀다.</p>
<p>또한 카메라 권한이 없다면 카메라가 안켜지기에 그 부분을 처리할 <strong>alert</strong>을 뒀다.</p>
<p>마지막으로 카메라 뷰가 나타날때 <strong>권한 체크를 위한 함수</strong>까지 뒀다.</p>
<blockquote>
<h3 id="cameraviewmodel">CameraViewModel</h3>
</blockquote>
<pre><code class="language-swift">import SwiftUI
import AVFoundation

class CameraViewModel: NSObject, ObservableObject {
    @Published var capturedImage: UIImage? //캡쳐된 이미지
    @Published var isAlertPresented: Bool = false //카메라 권한 없을때 띄울 alert

    let captureSession = AVCaptureSession() //capture세션

    private let photoOutput = AVCapturePhotoOutput() //cature output
    private var currentPosition: AVCaptureDevice.Position = .back //카메라 전면 or 후면 위치
    private let discoverySession = AVCaptureDevice.DiscoverySession( // DiscoverySession: 기기 목록 필터링
        deviceTypes: [.builtInTrueDepthCamera, .builtInDualCamera, .builtInWideAngleCamera],
        mediaType: .video,
        position: .unspecified
    )

    var isAuthorized: Bool {  //카메라 권한 있는지 계산 프로퍼티의 (get은 읽어들일때마다 실행)
        get async {
            let status = AVCaptureDevice.authorizationStatus(for: .video)

            var isAuthorized = status == .authorized

            if status == .notDetermined {
                isAuthorized = await AVCaptureDevice.requestAccess(for: .video) //카메라 권한 요청
            }

            return isAuthorized
        }
    }

    //MARK: - 카메라 권한 확인
    func checkCameraAuth() async {
        //A : 카메라 권한이 없을시 alert 띄우기
        guard await isAuthorized else {
            await MainActor.run {
                isAlertPresented = true
            }
            return
        }
        //B : 카메라 권한이 있을시 캡쳐 세션 세팅
        setUpCaptureSession()
    }

    //MARK: - 권한 설정으로 이동
    func goSetting() {
        if let url = URL(string: UIApplication.openSettingsURLString) {
            UIApplication.shared.open(url)
        }
    }

    //MARK: - 사진 촬영
    func takePhoto() {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        photoOutput.capturePhoto(with: settings, delegate: self)
    }

    //MARK: - 다시 찍기
    func retake() {
        capturedImage = nil
        Task {
            captureSession.startRunning()
        }
    }

    //MARK: - 카메라 전환
    func switchCamera() {
        // &lt;---- 설정변경 시작 ----&gt;
        captureSession.beginConfiguration()

        if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
            captureSession.removeInput(currentInput)  // 현재 Input 제거
            currentPosition = (currentInput.device.position == .back) ? .front : .back // 반대쪽 카메라 위치 선택

            if let newInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),  // 새로운 Input 설정
               captureSession.canAddInput(newInput) {
                captureSession.addInput(newInput)
            }
        }
        captureSession.commitConfiguration()
        // &lt;---- 설정변경 완료 ----&gt;
    }

    //MARK: - 캡쳐 세션 세팅
    private func setUpCaptureSession() {
        // &lt;---- 설정변경 시작 ----&gt;
        captureSession.beginConfiguration()
        guard let videoDeviceInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),
              captureSession.canAddInput(videoDeviceInput)
        else { return }
        captureSession.addInput(videoDeviceInput)

        guard captureSession.canAddOutput(photoOutput) else { return }
        captureSession.sessionPreset = .photo
        captureSession.addOutput(photoOutput)
        captureSession.commitConfiguration()
        // &lt;---- 설정변경 완료 ----&gt;

        captureSession.startRunning()
    }

    // MARK: - 최적 카메라 선택
    private func bestDevice(in position: AVCaptureDevice.Position) -&gt; AVCaptureDevice {
        let devices = self.discoverySession.devices
        guard !devices.isEmpty else { fatalError(&quot;Missing capture devices.&quot;)}
        return devices.first(where: { device in device.position == position })!
    }
}

// MARK: - AVCapturePhotoCaptureDelegate(사진 촬영 후 이미지화를 위한)
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput,
                     didFinishProcessingPhoto photo: AVCapturePhoto,
                     error: Error?) {
        guard let imageData = photo.fileDataRepresentation(),
              let image = UIImage(data: imageData) else { return }

        DispatchQueue.main.async {
            self.capturedImage = image
        }
        captureSession.stopRunning()
    }
}</code></pre>
<p>코드가 굉장히 많긴 하지만 차근차근 보자.
일단 CameraView에서 <strong>checkCameraAuth()</strong> 함수가 호출 될 때부터 시작된다. </p>
<hr>
<pre><code class="language-swift">
//MARK: - 카메라 권한 확인
    func checkCameraAuth() async {
        //A : 카메라 권한이 없을시 alert 띄우기
        guard await isAuthorized else {
            await MainActor.run {
                isAlertPresented = true
            }
            return
        }
        //B : 카메라 권한이 있을시 캡쳐 세션 세팅
        setUpCaptureSession()
    }</code></pre>
<ul>
<li><p>카메라 권한이 없다면 isAlertPresented를 true로 만들어 <strong>alert</strong>을 띄워준다. </p>
</li>
<li><p>카메라 권한이 있다면 캡쳐 세션을 세팅해주는 <strong>setUpCaptureSession()</strong> 메서드를 호출한다.</p>
</li>
<li><p><strong>isAuthorized</strong> 는 계산프로퍼티로 카메라 권한 상태를 관리하며 권한이 정해지지 않았으면 사용자에게 권한요청을 보낸다</p>
</li>
</ul>
<p>다음으로 setUpCaptureSession() 메서드를 살펴보겠다.</p>
<hr>
<pre><code class="language-swift">  //MARK: - 캡쳐 세션 세팅
    private func setUpCaptureSession() {
        // &lt;---- 설정변경 시작 ----&gt;
        captureSession.beginConfiguration()
        guard let videoDeviceInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),
              captureSession.canAddInput(videoDeviceInput)
        else { return }
        captureSession.addInput(videoDeviceInput)

        guard captureSession.canAddOutput(photoOutput) else { return }
        captureSession.sessionPreset = .photo
        captureSession.addOutput(photoOutput)
        captureSession.commitConfiguration()
        // &lt;---- 설정변경 완료 ----&gt;

        captureSession.startRunning() 
    }</code></pre>
<p>이 메서드는 앞서 말했던 Session을 설정하는 기능을 한다.</p>
<p><strong>beginConfiguration()</strong>과 <strong>commitConfiguration()</strong>은 각각 세션의 설정변경 시작을 알리고 변경된 설정을 저장하는 역할을 해주며, 세션 설정을 변경해줄때는 꼭 이 두 가지 메서드 사이에서 해줘야한다!</p>
<ul>
<li><p>먼저 세션에 <strong>Input</strong>을 붙이기 위해서는 카메라 장치(AVCaptureDevice)를 가져와야하는데 나는 bestDevice(in: )라는 메서드를 구현해 사용했다.
그후, 세션에 Input을 붙일 수 있는지 확인하고 붙이는 작업을 했다.</p>
</li>
<li><p>그리고 세션에 <strong>Output</strong>을 붙이기 위해서는 사진을 찍어 내보내는 장치(AVCapturePhotoOutput의 인스턴스)가 필요해 넣어주었고, sessionPreset = .photo를 통해 사진 촬영에 적합한 화질을 설정해주었다. 그후, 세션에 Output을 붙이는 작업을 해주었다.</p>
</li>
</ul>
<p>(빨대에 구멍이 하나만 있으면 안되니 Input, Output 모두 뚫어줘야 한다)</p>
<ul>
<li>Input, Output을 모두 세션에 붙여 세션 설정이 완료 되었으므로, <strong>startRunning()</strong>을 통해 세션을 실행해준다.</li>
</ul>
<p>여기까지만 해줘도 카메라를 시동시킬 수 있는 것이다 !!</p>
<p>다음으로는 카메라 전환 기능(전면/후면) <strong>switchCamera()</strong> 메서드를 보겠다.</p>
<hr>
<pre><code class="language-swift">//MARK: - 카메라 전환
    func switchCamera() {
        // &lt;---- 설정변경 시작 ----&gt;
        captureSession.beginConfiguration()

        if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
            captureSession.removeInput(currentInput)  // 현재 Input 제거
            currentPosition = (currentInput.device.position == .back) ? .front : .back // 반대쪽 카메라 위치 선택

            if let newInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),  // 새로운 Input 설정
               captureSession.canAddInput(newInput) {
                captureSession.addInput(newInput)
            }
        }
        captureSession.commitConfiguration()
        // &lt;---- 설정변경 완료 ----&gt;
    }</code></pre>
<p>카메라를 전환한다는 것은, <strong>세션 설정을 다시 해줘야 한다</strong>는 것이다.
그래서 처음 세션 설정을 해준것처럼 beginConfiguration() 과 commitConfiguration() 사이에서 세션 변경을 해준다.</p>
<ul>
<li>먼저 세션에서 붙여져 있는 Input 장치를 떼어준다.</li>
<li>현재 카메라의 전면/후면 위치에 따라 반대로 바꿔준다.</li>
<li>그 후, 아까처럼 미리 만들어둔 bestDevice(in: ) 메서드로 새로운 카메라 Input 장치를 세션에 붙여주고 세션 변경을 완료해준다.</li>
</ul>
<p>이렇게 간단하게 카메라 전환기능도 만들 수 있다 !</p>
<p>자, 여기서 두 번이나 쓰인 <strong>bestDevice(in: )</strong> 메서드와 <strong>discoverySession</strong> 을 살펴보자.</p>
<hr>
<pre><code class="language-swift">// DiscoverySession: 기기 목록 필터링
      private let discoverySession = AVCaptureDevice.DiscoverySession( 
        deviceTypes: [.builtInTrueDepthCamera, .builtInDualCamera, .builtInWideAngleCamera],
        mediaType: .video,
        position: .unspecified
    )
 // MARK: - 최적 카메라 선택
    private func bestDevice(in position: AVCaptureDevice.Position) -&gt; AVCaptureDevice {
        let devices = self.discoverySession.devices
        guard !devices.isEmpty else { fatalError(&quot;Missing capture devices.&quot;)}
        return devices.first(where: { device in device.position == position })!
    }
</code></pre>
<p>먼저  <strong>AVCaptureDevice.DiscoverySession</strong>은 카메라 장치 목록을 가져오는 필터 같은 역할을 한다.</p>
<ul>
<li>deviceTypes에 쓰이는 3가지는 각각 Face ID에서 쓰이는 전면 카메라, 듀얼 후면 카메라, 일반 광곽 카메라다.</li>
<li>mediaType: .video는 영상 촬영이 가능한 장치만 가져온다.</li>
<li>position: .unspecified는 전면/후면 특정되지 않게 가져온다.</li>
</ul>
<p><strong>bestDevice(in: )</strong> 메서드는 카메라 위치(전면/후면)을 인자로 받는다. </p>
<ul>
<li>위에서 만든 discoverySession의 카메라 리스트를 가져오고</li>
<li>전면 or 후면 조건에 맞는 카메라를 반환한다.</li>
</ul>
<p>아까처럼 세션 Input에 카메라를 연결해주는 역할을 하는 것이다 !</p>
<p>다음으로는 사진 촬영 기능에 대해 볼 것이므로 <strong>takePhoto()</strong>와 <strong>AVCapturePhotoCaptureDelegate</strong>에 대해 보겠다.</p>
<hr>
<pre><code class="language-swift">    //MARK: - 사진 촬영
    func takePhoto() {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        photoOutput.capturePhoto(with: settings, delegate: self)
    }

    // MARK: - AVCapturePhotoCaptureDelegate(사진 촬영 후 이미지화를 위한)
    extension CameraViewModel: AVCapturePhotoCaptureDelegate {
        func photoOutput(_ output: AVCapturePhotoOutput,
                         didFinishProcessingPhoto photo: AVCapturePhoto,
                         error: Error?) {
            guard let imageData = photo.fileDataRepresentation(),
                  let image = UIImage(data: imageData) else { return }

            DispatchQueue.main.async {
                self.capturedImage = image
            }
            captureSession.stopRunning()
        }
    }</code></pre>
<p>takePhoto() 메서드에서</p>
<ul>
<li><strong>AVCapturePhotoSettings()</strong>을 통해 촬영할때 필요한 설정(플래시 등)을 할 수 있다.</li>
<li>*<em>capturePhoto(with: settings, delegate: self) *</em>메서드를 시행하게 되면  delegate 메서드 photoOutput(_:didFinishProcessingPhoto:...)가 자동으로 호출된다.</li>
</ul>
<p>photoOutput(_:didFinishProcessingPhoto:...) 딜리게이트가 호출되면</p>
<ul>
<li>촬영결과를 <strong>fileDataRepresentation()</strong>을 통해 데이터로 변환한다.</li>
<li>변환한 UIImage를 메인스레드에서 <strong>self.capturedImage</strong>에 넣어준다.</li>
<li>사진 촬영 후 촬영된 이미지를 바로 보여줄거라 <strong>stopRunning()</strong>을 통해 세션을 종료시켜준다.</li>
</ul>
<p>이렇게 delegate패턴을 통해 아주 쉽게 사진 촬영 기능도 만들 수 있다.</p>
<p>만약 사진 촬영 후 다시 찍고 싶다면,</p>
<hr>
<pre><code class="language-swift"> //MARK: - 다시 찍기
    func retake() {
        capturedImage = nil
        Task {
            captureSession.startRunning()
        }
    }</code></pre>
<p>capturedImage를 nil로 만들어주고 세션을 startRunning()로 다시 실행시켜주면 된다 !</p>
<p>자 이제 카메라 기능들의 이야기가 끝났으니, 진짜로 카메라 프리뷰와 연결시켜주는 작업을 해줘야한다 !</p>
<hr>
<blockquote>
<h3 id="camerapreviewview">CameraPreviewView</h3>
</blockquote>
<p>카메라 프리뷰 뷰는 카메라 렌즈를 통해 보이는 실제세상의 뷰다. </p>
<pre><code class="language-swift">import SwiftUI
import AVFoundation

struct CameraPreviewView: UIViewRepresentable {
    let session: AVCaptureSession

    class PreviewView: UIView {
        override class var layerClass: AnyClass {
            return AVCaptureVideoPreviewLayer.self
        }

        var videoPreviewLayer: AVCaptureVideoPreviewLayer {
            return layer as! AVCaptureVideoPreviewLayer
        }
    }

    func makeUIView(context: Context) -&gt; PreviewView {
        let view = PreviewView()
        view.videoPreviewLayer.session = session //세션 일치 시켜주기

        return view
    }

    func updateUIView(_ uiView: PreviewView, context: Context) {
        //업뎃 로직 필요시
    }
}
</code></pre>
<p>SwiftUI 자체에는 카메라 미리보기 기능이 없으니까, UIKit의 <strong>AVCaptureVideoPreviewLayer</strong>를 감싸서 써야한다.</p>
<p>여기서 주의할 점은 videoPreviewLayer의 세션에 우리가 만들어 둔 세션을 <strong>연결시켜줘야</strong> 카메라가 정상적으로 작동하고 전환/촬영 기능도 작동한다.</p>
<p>여기까지 했다면 나만의 카메라 만들기에 성공했을 것이다.</p>
<hr>
<blockquote>
<h2 id="구현영상">구현영상</h2>
</blockquote>
<p align="center">
<img src="https://velog.velcdn.com/images/sheep_jh/post/ddfa76f4-d14d-4b75-8f6e-8084bd03ba64/image.gif" width="300"><p>

<hr>
<h3 id="🍎-참고한-자료">🍎 참고한 자료</h3>
<ul>
<li><a href="https://developer.apple.com/documentation/avfoundation">https://developer.apple.com/documentation/avfoundation</a></li>
<li><a href="https://developer.apple.com/documentation/avfoundation/capture-setup">https://developer.apple.com/documentation/avfoundation/capture-setup</a></li>
<li><a href="https://developer.apple.com/documentation/avfoundation/setting-up-a-capture-session">https://developer.apple.com/documentation/avfoundation/setting-up-a-capture-session</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] NavigationBar 커스텀하기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-NavigationBar-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-NavigationBar-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 14 May 2025 02:39:36 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h4 id="들어가며">들어가며</h4>
</blockquote>
<p>우선, 커스텀 네비게이션 바를 만들게 된 이유는 진행하고 있는 프로젝트에서 디자이너가 네비게이션 바의 디자인 시스템을 만들어놨고, 뷰마다 일일히 네비게이션 바를 만드는 것 보다 커스텀한 <strong>ViewModifier</strong>을 하나 만들고 <strong>재사용</strong>하는게 어떨까 싶었다.</p>
<p align="center">
<img src="https://velog.velcdn.com/images/sheep_jh/post/f3e628ae-f95a-474e-a706-9569596f8f1f/image.png" width="250"><p>
  디자이너가 만들어놓은 네비게이션 바의 디자인 시스템이다. (물론 이것보다 더 많다)


<blockquote>
<h4 id="custom-navigationbar">Custom NavigationBar</h4>
</blockquote>
<p>  바로 코드로 보자.</p>
<pre><code class="language-swift">import SwiftUI

struct CustomNavigationBarModifier&lt;L, C, R&gt;: ViewModifier
where L: View, C: View, R: View {
    let leftView: () -&gt; L
    let centerView: () -&gt; C
    let rightView: () -&gt; R
    let backgroundColor: Color //네비게이션 바 색상
    let borderColor: Color //외곽선 색상

    func body(content: Content) -&gt; some View {
        content
            .navigationBarBackButtonHidden(true)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    leftView()
                        .padding(.leading, -16)
                }
                ToolbarItem(placement: .principal) {
                   centerView()
                }
                ToolbarItem(placement: .topBarTrailing) {
                   rightView()
                        .padding(.trailing, -16)
                }
            }
            .onAppear{
                let navBarAppearance = UINavigationBarAppearance()
                navBarAppearance.backgroundColor = UIColor(backgroundColor)
                navBarAppearance.backgroundEffect = nil
                navBarAppearance.shadowColor = UIColor(borderColor)
                UINavigationBar.appearance().standardAppearance = navBarAppearance
                UINavigationBar.appearance().compactAppearance = navBarAppearance
                UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
            }
    }
}</code></pre>
<p>  ViewModifier을 통해 네비게이션바를 커스텀했고 네비게이션바의 <strong>left, center, right *<em>각각의 위치를 뷰로 받았다. 또한 네비게이션 바가 투명색인 경우와 외곽선이 존재하는 경우가 있어서 각각 *</em>backgroundColor</strong>와 <strong>borderColor</strong>로 받았다.<br></p>
<p>.toolbar { ToolbarItem() } 으로 각 위치에 맞게 뷰를 넣어줬는데 여기서 양옆 -16 패딩을 넣은 이유는 네비게이션바의 양옆 기본 마진이 16으로 설정되있기 때문에 뷰 모디파이어를 재사용할때 커스텀을 원점에서 할 수 있게 설정했다.
  <br>onAppear 부분에는 네비게이션 바를 커스텀하는 UIKit의 코드들을 작성해뒀다.</p>
<blockquote>
<h4 id="extension">Extension</h4>
</blockquote>
<pre><code class="language-swift">extension View {
    func customNavigationBar&lt;L: View, C: View, R: View&gt;(
        leftView: @escaping () -&gt; L = { EmptyView() },
        centerView: @escaping () -&gt; C = { EmptyView() },
        rightView: @escaping () -&gt; R = { EmptyView() },
        backgroundColor: Color = .bsBackground1,
        borderColor: Color = .bsBackground2
    ) -&gt; some View {
        self.modifier(CustomNavigationBarModifier(
            leftView: leftView,
            centerView: centerView,
            rightView: rightView,
            backgroundColor: backgroundColor,
            borderColor: borderColor
        ))
    }
}</code></pre>
<p>  View에서 모디파이어를 더 간결하게 활용할 수 있게 extension 해주는 코드다.</p>
<blockquote>
<h4 id="실제사용">실제사용</h4>
</blockquote>
<pre><code class="language-swift"> .customNavigationBar (
            leftView : {
                Image(&quot;back&quot;)
            },
            centerView: {
                  Text(&quot;Title&quot;)
            }
   )</code></pre>
<p>  실제로 뷰에서는 .customNavigationBar() 모디파이어만 적용하면 된다. 위 코드에서는 left와 center만 설정했지만 필요에 따라 right나 background, border을 적용할 수 있다.</p>
<p>  <br><br>
  🍎<strong>참고사이트</strong></p>
<p>  <a href="https://0urtrees.tistory.com/360">https://0urtrees.tistory.com/360</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI] 멀티터치 막기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUI-%EB%A9%80%ED%8B%B0%ED%84%B0%EC%B9%98-%EB%A7%89%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUI-%EB%A9%80%ED%8B%B0%ED%84%B0%EC%B9%98-%EB%A7%89%EA%B8%B0</guid>
            <pubDate>Wed, 30 Apr 2025 07:06:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h4 id="들어가며">들어가며</h4>
</blockquote>
<p>멀티터치에 대하여 글을 쓰게된 이유는, 프로젝트를 진행하던 중 멀티터치, 중복터치를 막아야 하는 상황이 있었고 해결한 방법을 공유하기 위해서다.<br>
우선, swiftui 에서는 멀티터치를 지원하고 있는데 멀티터치는 여러개의 콘텐츠를 동시에 탭할때 하나의 콘텐츠만 작용하는게 아니라 모두 다같이 작용한다.<br></p>
<p>다들 한 번쯤은 앱을 사용하다가</p>
<p align="center">
<img src="https://velog.velcdn.com/images/sheep_jh/post/54bbea6f-feec-4151-b20a-2c13f982fdba/image.gif" width="300"><p>





<p>  이런식으로 네비게이션이 중첩되어 넘어가본 경험이 있을 것이다. 이러한 경험은 사용자에게 좋지 않은 경험이 될 수도 있고, 멀티터치로 여러 콘텐츠가 동시에 넘어간다면 서버 비용측면에서도 좋지 않을 것이다. 뿐만 아니라 동시에 여러 기능이 구현되서 기능이 꼬이는 문제가 발생할 수도 있을 것이다.<br></p>
<p>  이러한 내용들을 종합해보면, 멀티터치를 막는 기능을 구현하면 더욱 완성도 있는 서비스를 제공할 수 있을것이다.</p>
<h2 id="첫-번째-방법">첫 번째 방법</h2>
<p>  첫 번째 방법으로는 앱 전역에서 멀티터치를 막는 것이다. </p>
<pre><code class="language-swift">@main
struct YourApp: App {
    init() {
        UIView.appearance().isMultipleTouchEnabled = false
        UIView.appearance().isExclusiveTouch = true
    }
}</code></pre>
<p>  이런식으로 적용하면 앱 전역에서 멀티터치, 중복터치를 막을 수 있다고 한다. 그러나 필자는 이 방법으로 적용해보니 계속해서 실패해서 다른 방법을 이용했다.</p>
<h2 id="두-번째-방법">두 번째 방법</h2>
<p>  두 번째 방법으로는 TapLock이라는 싱글톤 객체를 생성하여 각 버튼, 또는 OnTapGesture에 메서드를 적용하는 것이다.</p>
<pre><code class="language-swift">import SwiftUI

final class TapLock {
    static let shared = TapLock()
    private init() {}

    private var isLocked = false

    func tryLock(for duration: TimeInterval = 0.5) -&gt; Bool {
        guard !isLocked else { return false }
        isLocked = true
        DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
            self.isLocked = false
        }
        return true
    }
}</code></pre>
<p>  이렇게 TapLock 클래스를 싱글톤으로 만들어두고 tryLock() 메서드를 각 버튼에 적용하게 되면 하나의 버튼을 눌렀을때 lock이 걸리고 0.5초가 지났을때 다시 lock이 해제된다.</p>
<p>  이걸 View에서 적용한다면 </p>
<pre><code class="language-swift">import SwiftUI

struct TestView: View {
    var body: some View {
        VStack {
            Button(&quot;버튼 1&quot;) {
                guard TapLock.shared.tryLock() else { return }
                print(&quot;버튼 1 탭&quot;)
            }
            Button(&quot;버튼 2&quot;) {
                guard TapLock.shared.tryLock() else { return }
                print(&quot;버튼 2 탭&quot;)
            }
        }
    }
}
</code></pre>
<p>이런식으로 내가 구현하고 싶은 기능 앞에  </p>
<pre><code class="language-swift">guard TapLock.shared.tryLock() else { return }</code></pre>
<p>  이 싱글톤 메서드를 통해 lock이 걸려있을 경우 return 하여 멀티터치, 중복터치를 막을 수 있다.</p>
<blockquote>
<h4 id="마치며">마치며</h4>
</blockquote>
<p>  앱 전역에 두 줄의 코드로 멀티터치를 막는 방법이 성공했다면 참 편했겠지만, 실패하여 싱글톤으로 멀티터치를 막는 방법을 택했다. 멀티터치 막기가 제대로 작동하지만 모든 버튼, onTapGesture에 메서드를 적용해야 하는 이 방법은 비효율적이라는 생각이 들었다. 하지만 아직은 앱 전역에 한 번에 멀티터치를 막는 방법을 알아내지 못해서 안타까울 뿐이다. 그래도 멀티터치를 막고 싶은 개발자들에게 이 글이 도움이 되었으면 좋겠다 ~
  <br></p>
<h4 id="🍎-참고">🍎 참고</h4>
<p>  <a href="https://stackoverflow.com/questions/67455293/how-to-disable-multi-touch-on-entire-app-or-just-a-view-using-swiftui">https://stackoverflow.com/questions/67455293/how-to-disable-multi-touch-on-entire-app-or-just-a-view-using-swiftui</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SwiftUI/TCA] 토스트 팝업 띄우기]]></title>
            <link>https://velog.io/@sheep_jh/SwiftUITCA-%ED%86%A0%EC%8A%A4%ED%8A%B8-%ED%8C%9D%EC%97%85-%EB%9D%84%EC%9A%B0%EA%B8%B0</link>
            <guid>https://velog.io/@sheep_jh/SwiftUITCA-%ED%86%A0%EC%8A%A4%ED%8A%B8-%ED%8C%9D%EC%97%85-%EB%9D%84%EC%9A%B0%EA%B8%B0</guid>
            <pubDate>Wed, 09 Apr 2025 13:12:40 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h4 id="들어가며">들어가며</h4>
</blockquote>
<p>진행하고 있는 프로젝트에서 토스트 팝업을 띄우게 되었는데, 토스트 팝업을 띄우는게 처음이라 어떻게 해야할 지 감이 안 잡혔습니다. 토스트는 sheet나 fullscreencover를 이용해서 띄우지 않기 때문에 더 난감했습니다. 그래서 그냥 커스텀 토스트를 만들기로 결심했습니다.</p>
<blockquote>
<h4 id="custom-toast">Custom Toast</h4>
</blockquote>
<pre><code class="language-swift">import SwiftUI

struct Toast: View {
    let text: String
    @Binding var isVisible: Bool
    @State private var toastID = UUID()
        var body: some View {
            if isVisible {
                Text(text)
                    .font(.subheadline)
                    .foregroundColor(.white)
                    .frame(maxWidth: UIScreen.main.bounds.width - 40)
                    .frame(height: 36)
                    .background(
                        RoundedRectangle(cornerRadius: 6)
                            .fill(.black)
                    )
                    .contentShape(Rectangle())
                    .opacity(isVisible ? 1 : 0) 
                    .id(toastID)
                    .onAppear {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                            withAnimation(.easeOut(duration: 1.5)) {
                                isVisible = false
                            }
                        }
                    }
            }
        }
}</code></pre>
<p>토스트 팝업 같은 경우에 앱에서 하나의 UI를 중복으로 사용하기 때문에 모듈화 해두었습니다. 토스트 텍스트 같은 경우에는 외부에서 작성할 수 있게 구성했고, 토스트가 사라지는 상태를 isVisible로 바인딩 했습니다.<br><br>토스트 .id()에 UUID()를 넣은 이유는 서로 다른 토스트가 중복으로 띄워졌을때 토스트가 없어지지 않는 이슈를 막기 위해서 입니다.<br><br> </p>
<blockquote>
<h4 id="appfeature">AppFeature</h4>
</blockquote>
<pre><code class="language-swift">import ComposableArchitecture
import SwiftUI

@Reducer
struct AppFeature {
    @ObservableState
    struct State: Equatable {
        var path = StackState&lt;Path.State&gt;()
        var toastText: String = &quot;&quot;
        var isToastVisible: Bool = false
    }
    enum Action: BindableAction {
        case binding(BindingAction&lt;State&gt;)
        case path(StackActionOf&lt;Path&gt;)
        case nextViewButtonTapped
        case toastButtonTapped
    }
    var body: some ReducerOf&lt;Self&gt; {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .nextViewButtonTapped:
                state.path.append(.detail(DetailFeature.State()))
                return .none

            case .toastButtonTapped:
                state.toastText = &quot;첫 번째 토스트 입니다.&quot;
                state.isToastVisible = true
                return .none

            case .path(.element(id: _, .detail(.toastButtonTapped))):
                state.toastText = &quot;두 번째 토스트 입니다.&quot;
                state.isToastVisible = true
                return .none

            case .binding, .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }
}


extension AppFeature {
    @Reducer
    enum Path {
        case detail(DetailFeature)
    }
}

extension AppFeature.Path.State: Equatable {}</code></pre>
<p>AppFeature는 최상위 피쳐고, 대부분의 앱은 Navigation으로 이루어져있기 때문에 제일 상위 뷰에서 toast를 제어하기로 했습니다. <strong>toastText</strong>와 <strong>isToastVisible</strong>를 선언해주고 버튼이 눌렸을때 토스트 텍스트를 넣고, 토스트 팝업이 띄워지도록 구현했습니다. 또한, 그 다음 뷰에서도 또 다른 토스트 팝업이 띄워지도록 구현했습니다.</p>
<blockquote>
<h4 id="appview">AppView</h4>
</blockquote>
<pre><code class="language-swift">struct AppView: View {
    @Bindable var store: StoreOf&lt;AppFeature&gt;

    var body: some View{
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            VStack {
                Button(&quot;다음 뷰로 이동&quot;) {
                    store.send(.nextViewButtonTapped)
                }
                Button(&quot;토스트 팝업 띄우기&quot;) {
                    store.send(.toastButtonTapped)
                }
            }
        } destination: { store in
            switch store.case {
            case .detail(let store):
                DetailView(store: store)
            }
        }
        .overlay(
            Toast(text: store.toastText, isVisible: $store.isToastVisible)
                .padding(.bottom, 12),
            alignment: .bottom
        )

    }
}</code></pre>
<p><strong>.overlay(, alignment: .bottom)</strong>을 NavigationStack에 위치시킴으로 모든 뷰에서 똑같은 위치에 토스트 팝업이 띄워지도록 구성했습니다.</p>
<blockquote>
<h4 id="detailfeature-view">DetailFeature, VIew</h4>
</blockquote>
<pre><code class="language-swift">import ComposableArchitecture

@Reducer
struct DetailFeature {
    @ObservableState
    struct State: Equatable {
    }

    enum Action {
        case toastButtonTapped
    }
    var body: some ReducerOf&lt;Self&gt;{
        Reduce{ state, action in
            switch action {
            case .toastButtonTapped:
                return .none
            }

        }
    }
}

import SwiftUI

struct DetailView: View {
    let store: StoreOf&lt;DetailFeature&gt;

    var body: some View{
        Button(&quot;토스트 팝업 띄우기&quot;) {
            store.send(.toastButtonTapped)
        }
    }
}</code></pre>
<p>DetailFeature에서는 버튼과 toastButtonTapped 액션만 구현해놓고 AppFeature에서 토스트를 관리할 수 있게 구현했습니다.</p>
<blockquote>
<h4 id="구현-영상">구현 영상</h4>
</blockquote>
<p align="center">
<img src="https://velog.velcdn.com/images/sheep_jh/post/d833835e-e00e-4157-a487-499c78c6d7d2/image.gif" width="300"><p>
]]></description>
        </item>
    </channel>
</rss>