스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI로 맞춤형 시각 효과 제작하기
SwiftUI에서 놀라운 시각 효과를 제작하는 방법을 알아보세요. 개성 있는 스크롤 효과, 풍부한 색상 처리, 맞춤형 전환 효과를 빌드하는 방법을 소개합니다. Metal 셰이더 및 맞춤형 텍스트 렌더링을 사용한 고급 그래픽 효과도 살펴보세요.
챕터
- 0:00 - Introduction
- 1:29 - Scroll effects
- 6:21 - Color treatments
- 9:10 - View transitions
- 12:49 - Text transitions
- 19:40 - Metal shaders
- 25:28 - Next steps
리소스
-
다운로드
‘SwiftUI로 맞춤형 시각 효과 제작하기’에 오신 것을 환영합니다 저는 Philip이고 잠시 후 Rob이 합류할 예정입니다 이번 세션에서는 다채롭고 사용하기 좋은 앱을 만들기 위해 시각 효과를 생성하는 방법을 다루겠습니다 우수한 앱 경험은 여러 작은 개선 사항을 도입한 결과 완성되는 경우가 많습니다 작은 변화가 큰 차이로 이어지죠 시각 효과는 앱이 사용 및 인식되는 방식에 큰 영향을 미칠 수 있죠 시각 효과는 특정 기능이 예상대로 작동함을 보여 주고 앱 화면에 개성을 더하고 진행 중인 중요한 일에 주의를 집중시키죠 새로운 시각 효과를 만들 때는 빌드를 시작하기 전에는 무엇이 적합할지 알 수 없는 경우가 많죠 적합한 효과를 찾을 때까지 실험하고, 수정하고, 다양한 아이디어를 시도해야 합니다 이 세션에서는 저와 Rob이 몇 가지 예시를 통해 SwiftUI로 맞춤형 스크롤 효과를 만드는 방법과 메시 그라디언트로 앱에 풍부한 색상을 적용하는 방법 맞춤형 보기 전환을 구성하는 방법 텍스트 렌더링으로 근사한 텍스트 전환을 만드는 방법 금속 셰이더를 작성하여 고급 그래픽 효과를 만들어 볼게요 먼저 여러분에게 익숙할 내용으로 시작할게요 스크롤입니다 우리가 사용하는 대부분의 앱 경험은 스크롤하면서 확인하는 항목의 모음입니다 사진, 동영상, 텍스트 블록 등의 항목을 보게 되죠 스크롤 보기는 많은 경험에서 사용됩니다
가로형 스크롤 보기 안에 있는 간단한 사진 모음입니다 SwiftUI의 스크롤 보기는 흔한 사용 사례를 위한 자동 지원을 많이 제공하죠
여기에 페이징 동작을 사용하여 페이지 나누기 효과를 적용했죠 표준 스크롤 보기로서 충분합니다 하지만 저는 더 독특한 경험을 만들고 싶습니다
사진 한 장을 볼게요
SwiftUI의 scrollTransition 한정자는 표준적인 요소 모음을 맞춤형 모음으로 변경할 때 사용할 수 있습니다
스크롤 전환은 전환하려는 콘텐츠와 위상을 노출시킵니다
이러한 값을 사용하여 스크롤 보기에서 회전수와 사진별 오프셋을 변경할 수 있습니다 사진의 위치를 기준으로 하죠
스크롤하면 양쪽에서 이어지는 사진이 회전하면서 원형 캐러셀 효과를 만듭니다
value 속성을 통해 이미지가 화면에서 떨어진 거리를 결정하고 회전 효과에 사용할 수 있죠 또한 보기가 화면에 완전히 표시되면 isIdentity 속성이 true가 됩니다
이 회전 효과도 멋있지만 제가 원하는 효과는 아니에요 저는 각 카드가 창문처럼 보였으면 합니다 사용자가 들여다볼 수 있는 창문이요
한정자를 변경하면 scrollTransition이 업데이트되죠 이 스크롤 보기의 느낌을 완전히 바꾸어 시차 효과를 만들 수 있습니다
여기에서 scrollTransition으로 이미지의 xOffset은 변경하되 이미지를 자르는 모양은 바꾸지 않죠 scrollTransition을 사용하면 다양한 방식으로 콘텐츠를 조작할 수 있습니다
스크롤 값에 따라 업데이트할 모든 콘텐츠에 이 한정자를 적용할 수 있습니다 여기서는 이미지 아래의 텍스트 캡션에 스크롤 전환을 추가하여 이미지가 페이드 아웃되고 오프셋되어 스크롤 보기의 모멘텀을 강화했습니다
scrollTransition은 흥미롭고 독특한 스크롤 경험을 만드는 데 유용합니다 그러나 보기의 위치나 크기가 시각적 모양에 미치는 영향을 더 세밀하게 제어해야 하는 경우도 있죠
스크롤하면서 볼 수 있는 간단한 식료품 모음입니다 현재는 모든 항목이 같은 색상으로 표시되므로 단조롭게 보입니다
visualEffect 한정자를 추가하면 content 플레이스홀더와 proxy에 접근할 수 있습니다 content 플레이스홀더의 작동 방식은 scrollTransition에서와 동일합니다 proxy는 보기의 지오메트리 값을 제공합니다
proxy의 보기 위치를 사용하여 보기의 색조를 변경하면 멋진 그라디언트 효과가 만들어집니다 기기에서 보기의 위치가 낮을수록 색조 회전이 더 강합니다
visualEffect 한정자를 사용하면 보기 위치와 크기에 따라 시각적 속성을 효과적으로 변경할 수 있으므로 스크롤 보기에서 사용하기 좋습니다
색상 대신 다른 시각적 속성을 변경할 수도 있습니다 여기서는 도형과 동일한 y 위치를 사용하여 스크롤 보기 상단에 있는 요소에 오프셋, 크기 조정, 페이드 및 블러 효과를 적용하고 있습니다 ScrollTransition과 VisualEffect 한정자는 맞춤형 스크롤 보기 효과를 만들 때 매우 유용합니다
이를 사용하여 화면에서 요소의 위치에 따라 스케일을 조정하는 스크롤 보기를 만들 수 있습니다
또한 원근감을 바꾸기 위해 활용할 수 있죠 회전 및 기울이기와 같은 다양한 변형 효과를 사용하면 됩니다
오프셋을 통해 스택 동작을 만들거나 밝기, 채도, 색조와 같은 색상 속성을 조정하여 내용을 강조하고 명확하게 표시할 수 있죠
특정 효과가 앱에 적합한지 또는 산만하기만 한지 항상 분명하지는 않습니다 시각 실험을 수행하면 많은 도움이 됩니다 시각 효과는 참신함이 사라진 후에도 사용하기 편해야 합니다 긴 시간을 걸쳐 다양한 맥락에서 효과를 테스트하면 효과가 잘 작동하는지 또는 아직 개선이 필요한지 파악할 수 있습니다 다음으로 색상 효과를 앱에 적용하는 방법을 설명할게요
색상은 인터페이스에서 중요한 역할을 합니다 앱에 개성을 더하거나, 주의를 집중시키거나 의도를 명시할 수 있죠 SwiftUI는 앱에 색상을 적용하는 도구를 다양하게 제공합니다 다양한 그라디언트 유형과 색상 컨트롤 블렌드 모드 등을 지원합니다
이제 SwiftUI는 메시 그라디언트도 지원합니다 메시 그라디언트는 동적 배경을 적용하거나 표면에 시각적 구분을 추가해야 할 때 유용합니다
메시 그라디언트는 점의 그리드로 만들어집니다 각 점은 색상과 연관되어 있습니다
SwiftUI는 그리드 내 색상 사이에서 보간하여 색상 채우기를 만듭니다
이러한 점을 이동하면 아름다운 색상 효과를 만들 수 있죠 색상이 매끄럽게 블렌딩되고 가까운 점은 색상이 더 선명하게 전환됩니다
새로운 MeshGradient 보기로 메시 그라디언트를 만들어 볼게요
width 및 height 매개변수를 사용하여 그리드의 행과 열을 정의하겠습니다 여기서는 3x3 그리드를 사용할게요
다음으로 이 3x3 그리드 내 X 및 Y 좌표의 위치를 정의하겠습니다 그리드 내 점은 SIMD2 부동 값으로 정의됩니다 보기로 사용될 때 이 부동 값은 X 및 Y 축에서 0부터 1까지의 값을 갖습니다
마지막으로, 각 점에 해당하는 색을 추가합니다
이렇게 하면 메시 그라디언트가 생성됩니다 지금은 선형 그라디언트처럼 보입니다 가운데 점의 X 및 Y 좌표를 옮기면 새 위치에 따라 색상이 이동합니다
메시 그라디언트는 앱에 색상 효과를 추가할 때 효과적이며 온갖 종류의 시각 효과를 만드는 데 활용할 수 있습니다 순전히 장식용으로 사용할 수 있지만 표면 색상을 이미지에 맞춰 바꾸거나 메시 그라디언트 애니메이션으로 변경 사항이 있음을 알릴 수도 있죠
제어 점의 위치, 그리드 크기 색상 팔레트 등의 값을 다양하게 바꾸어 보세요 매개변수를 조정해 보고 다양한 시각 효과를 테스트하면 더 풍부한 아이디어를 얻을 수 있으니 과감하게 실험해 보세요 최대한 많이 실험하고 새 효과를 만들어 보세요 다음으로 맞춤형 전환을 만드는 방법을 다룰게요 사용자는 인터페이스를 통해 앱이 백그라운드에서 어떤 작업을 수행하는지 알 수 있으며 전환은 일어나는 변경 사항을 전달할 때 유용합니다
전환은 새 보기를 표시하거나 더 이상 필요하지 않은 보기를 제거할 때 효과적입니다
전환은 변경된 내용과 변경 사항이 발생한 이유에 대한 정보를 제공할 때 도움이 될 수 있습니다 이러한 전환은 버튼을 탭할 때 발생할 수 있으며 사용자가 요소를 드래그해서 나타날 수도 있죠 때로는 다른 사용자가 앱을 사용한 결과 전환이 실행될 수 있습니다
이 아바타 보기는 사용자의 온라인 상태에 따라 표시되거나 숨겨지죠 사용자가 온라인 상태면 아바타가 표시되고 그렇지 않으면 숨겨지도록 만들고 싶습니다
현재는 아바타가 나타났다가 사라지기만 합니다 다소 어색하므로 전환을 추가해 보겠습니다
SwiftUI의 표준 전환 중 하나를 적용할 수 있습니다 scale은 아바타가 등장하고 없어질 때 크기를 늘리거나 줄이죠
여러 전환을 변경하고 싶은 경우 combined 메소드로 다른 전환을 추가할 수 있습니다 scale 전환과 opacity 전환을 같이 사용해 볼게요
훨씬 낫네요, 그런데 만약 더 맞춤화된 전환이 필요하다면요?
맞춤형 전환을 만들기 위해 새 struct를 생성하겠습니다 Twirl이라고 부를게요 이 struct는 Transition 프로토콜을 준수할 것입니다
Transition 본문 함수는 content와 phase 매개변수를 사용합니다 content 매개변수의 작동 방식은 스크롤 보기에서의 방식과 같죠 전환할 콘텐츠에 대한 플레이스홀더로 작동합니다 phase 값으로 보기가 현재 표시되는지 확인할 수 있으며 이를 활용하여 보기에 조건부 스타일을 적용할 수 있죠
scale의 경우, 보기를 표시할 때는 최대 스케일로 표시하고 보기를 표시하지 않을 때는 스케일을 절반으로 지정하겠습니다
opacity의 경우 요소가 완전히 표시되는 상태와 숨겨지는 상태를 전환하도록 설정할게요
맞춤형 전환을 보기에 추가하고 결과를 확인해 보겠습니다
맞춤형 전환으로 돌아갈게요 이제는 블러를 추가하여 아바타가 초점에 들어오고 나가는 것처럼 보이도록 하겠습니다 또한 회전도 추가하여 아바타가 돌도록 할게요
phase 값을 확인하면 보기가 표시될지 또는 사라졌는지 알 수 있습니다 이렇게 하면 아바타가 사라질 때도 같은 방향으로 회전합니다 음수 값을 사용했기 때문이죠
마지막으로 brightness 한정자를 추가하여 보기가 표시될 때 약간의 빛을 내어 시선을 끌 수 있도록 할게요
몇 가지 요소만 조정하면 인터페이스 요소가 우아한 방식으로 변화에 대해 반응합니다
전환은 다양한 상황에서 활용할 수 있습니다 로드되는 동안 요소가 서서히 표시되도록 하거나
중요한 정보를 제공하거나 역동적인 방식으로 그래픽 요소를 표시할 수 있습니다
좋은 전환은 큰 맥락에서 자연스럽게 어울리며 억지로 끼워 넣은 느낌을 주지 않습니다 앱을 전체적으로 살펴보면 앱에 적합한 전환을 결정하는 데 도움이 될 수 있습니다 전환에 대한 이야기가 나왔으니 이제 Rob이 텍스트 전환을 소개해 드릴 것입니다
Phillip, 고마워요 바로 시작하겠습니다
이 불투명도 전환과 같이 기본 제공되는 SwiftUI 전환으로 보기에서 애니메이션을 적용하는 방법을 Philip이 설명해 드렸죠 물론 내장된 한정자를 사용하여 전환을 꾸밀 수 있겠지만 이번에는 텍스트 라인마다 애니메이션을 추가하려고 합니다
이를 위해 TextRenderer를 사용할게요 이는 iOS 18 및 관련 릴리즈에 도입된 새 API입니다 강력한 프로토콜인 TextRenderer를 사용하면 View 트리 전체에서 SwiftUI 텍스트가 그려지는 방식을 맞춤화할 수 있습니다 완전히 새로운 맞춤형 텍스트를 그릴 수 있지만 제가 가장 좋아하는 기능은 애니메이션입니다
TextRenderer 프로토콜의 핵심은 draw(layout:in:) 메소드입니다 이 메소드의 인수는 Text.Layout 및 GraphicsContext입니다 Text.Layout은 텍스트의 개별 구성 요소인 라인, 런, 글리프에 접근할 수 있도록 합니다 GraphicsContext는 캔버스 보기에서 사용되는 것과 같습니다 이를 사용하여 그리는 방식을 자세히 알아보려면 ‘SwiftUI 앱에 풍부한 그래픽 추가하기’ 세션을 시청해 보세요
최소한의 요소를 갖춘 TextRenderer의 경우 for 루프를 사용하여 레이아웃의 개별 라인을 반복하고 컨텍스트에 그리면 됩니다 이렇게 하면 기본 렌더링 동작이 제공됩니다
전환 유도를 위해 속성 3개를 TextRenderer에 추가할게요 elapsedTime은 지금까지 경과한 시간을 지정하고 elementDuration은 개별 라인이나 요소에 애니메이션을 표시하는 데 사용할 시간이며 totalDuration은 전체 전환에 소요되는 시간입니다 SwiftUI가 elapsedTime 값에 자동으로 애니메이션을 적용할 수 있도록 Animatable 프로토콜을 구현할게요 이 경우 animatableData 속성을 elapsedTime으로 전달하면 간단하게 채택할 수 있습니다
이제 애니메이션 반복을 시작할 수 있습니다 먼저 라인별로 애니메이션을 적용해 보겠습니다 애니메이션 전체에 사용 가능한 시간을 균등하게 분배하려면 연속된 두 라인 사이의 지연 시간을 계산해야 합니다 제가 호출한 헬퍼 함수 elementDelay(count:)를 사용해서요 그런 다음 모든 라인을 나열하고 인덱스와 해당 지연 값을 기준으로 상대적인 시작 시간을 계산합니다 개별 라인에서 경과한 시간은 전체 경과 시간에서 요소의 개별 시간 오프셋을 뺀 값입니다 이 값도 범위를 한정합니다 다음으로 현재 GraphicsContext의 사본을 만듭니다 이렇게 하면 GraphicsContext에 값 시맨틱이 있으므로 헬퍼 함수에 대한 개별 호출이 서로 영향을 주지 않습니다
마지막으로 헬퍼 함수를 호출하여 개별 라인을 그립니다
이제 마법과 같은 일이 벌어지죠 라인을 그리기 전에 애니메이션을 적용하려는 GraphicsContext의 속성을 업데이트합니다 더 쉽게 처리하기 위해 분수 진행 값도 계산하겠습니다
먼저 라인이 페이드 인되도록 빠른 불투명도 경사를 계산합니다
동시에 blurRadius를 0으로 줄여 라인이 확산된 상태에서 나타나는 느낌을 줍니다
첫 blurRadius는 라인의 typographicBounds 속성에서 읽은 라인의 높이를 기반으로 합니다
마지막으로 spring을 사용하여 y축 translation에 애니메이션을 적용하죠
라인의 디센더 길이에 따라 위쪽으로 이동하는 y 위치에서 시작합니다 마지막으로 새 draw options 메소드로 라인을 그립니다
서브 픽셀 양자화를 해제하면 spring을 안정화시키고 지터를 방지할 수 있죠
Renderer로 텍스트에 애니메이션을 적용하려면 Philip이 설명한 것처럼 맞춤형 Transition을 구현하면 됩니다 실험해 본 결과 제 사용 사례에는 0.9초가 적합할 것 같습니다 하지만 현재 트랜잭션에 이미 애니메이션이 있을 수 있다는 점을 고려해야 합니다 예를 들어 이 전환이 withAnimation에 대한 호출에서 트리거된 경우입니다
트랜잭션 본문 보기 한정자를 사용하면 적절할 때 애니메이션을 재정의할 수 있습니다 이러면 모든 라인에 대해 균일한 선형 페이싱을 보장할 수 있습니다 그다음 새로운 textRenderer 보기 한정자를 사용하고 전환되는 보기에 맞춤형 렌더러를 설정합니다
전환을 작동시켜 보겠습니다
좋긴 하지만 엄청 마음에 들지는 않습니다 이 전환은 라인 수에 의존하고 있는데 라인 수는 로케일이나 Dynamic Type 크기에 따라 달라질 수 있거든요 이 전환에 포함된 시각 효과는 흥미롭지도 않습니다 이번에는 글리프마다 애니메이션을 적용할게요
이를 위해서는 Text.Layout의 run 슬라이스를 반복해야 합니다 이 슬라이스는 글리프나 내장된 이미지처럼 레이아웃의 가장 작은 단위를 나타냅니다
Text.Layout은 라인의 모음입니다 라인은 런의 모음이고 런은 RunSlice의 모음입니다
따라서 flattenedRunSlices라는 헬퍼 메소드를 사용하면 RunSlice만 반복하면 되며 로직을 대부분 유지할 수 있습니다
또한 헬퍼 함수를 조금 수정해야 합니다 하지만 Line 인수의 유형과 이름을 RunSlice로 변경하기만 하면 됩니다
그 결과는 이렇습니다 아까보다 나아졌지만 이제 다른 문제가 생겼네요 개별 글리프에 집중하기에는 애니메이션의 시간이 부족합니다 전환이 인상적으로 다가오지 않으며 전환이 재미없어 보이고 이전과 큰 차이도 나지 않습니다 코드로 다시 돌아가야겠어요 모든 항목에 동일한 애니메이션을 적용하는 대신 Visual Effects라는 단어에만 집중하겠습니다
이렇게 하면 전환을 사용하여 콘텐츠를 가져올 뿐만 아니라 중요한 내용을 강조할 수 있죠
이를 위해 iOS 18 및 관련 릴리즈에서 TextRenderer와 함께 도입된 새로운 TextAttribute 프로토콜을 사용하겠습니다 이 프로토콜을 구현하면 텍스트에서 TextRenderer로 데이터를 전달할 수 있죠
속성을 적용하는 것은 매우 간단합니다 customAttribute 텍스트 한정자를 사용하여 Visual Effects 단어를 맞춤형 EmphasisAttribute로 표시할게요 특정 범위의 텍스트를 표시하는 데만 사용했으므로 TextAttribute struct에 멤버 변수를 추가하지 않아도 됩니다
draw 메소드를 다시 살펴보죠 이제 layout의 flattenedRuns를 반복합니다 Attribute-Type을 키로 사용하는 하위 스크립트를 사용하여 run에 EmphasisAttribute가 있음을 확인합니다
속성이 존재하는 경우 이전과 완전히 동일한 방식으로 슬라이스를 반복합니다 속성이 없는 경우 0.2초에 걸쳐 run에서 빠르게 페이드 인됩니다
최종 결과는 이렇습니다 훨씬 낫네요 전환이 Visual Effects를 잘 강조합니다
TextRenderer는 새로운 가능성을 열어줍니다 보기를 개별적인 애니메이션을 갖는 구성 요소로 나누면 더 다채로운 애니메이션과 시각 효과를 만들 수 있습니다 SwiftUI에는 더욱 세밀한 제어를 제공하는 강력한 그래픽 API가 또 있습니다 셰이더입니다 셰이더는 다양한 렌더링 효과를 기기의 GPU에서 바로 계산하는 작은 프로그램입니다 SwiftUI에서는 내부적으로 셰이더를 사용하여 앞서 Philip이 소개한 새로운 메시 그라디언트처럼 여러 시각 효과를 구현합니다 iOS 17 및 관련 릴리즈에 도입된 SwiftUI 셰이더를 사용하면 성능은 유지하면서 나만의 놀라운 시각 효과를 작성할 수 있습니다
SwiftUI에서 셰이더를 인스턴스화하려면 ShaderLibrary에서 셰이더의 이름을 갖는 함수를 호출하면 되죠 여기에서 추가 매개변수를 셰이더 함수에 전달할 수도 있습니다 색상, 숫자, 이미지 등이 있겠죠 layerEffect 보기 한정자로 보기에 이 효과를 적용하면 SwiftUI가 보기의 모든 픽셀에 대해 셰이더 함수를 호출합니다
픽셀이 정말 많네요 이를 실시간으로 처리하기 위해 셰이더는 이와 같이 고도로 병렬화된 작업에 최적화된 기기의 GPU에서 실행됩니다 그러나 GPU 프로그래밍의 특성으로 인해 셰이더 자체는 Swift로 작성할 수 없습니다 대신 Metal Shading Language로 작성됩니다 줄여서 Metal이라고 하죠
앞서 보여드린 셰이더의 Metal 파일입니다 셰이더 함수의 이름이 ShaderLibrary에서 호출한 것과 일치하죠
SwiftUI는 각 보기 픽셀에 대해 이 함수를 GPU에서 실행하게 되며 실행 시 position 인수는 해당 픽셀의 위치를 나타냅니다 한편 layer 인수는 보기 콘텐츠를 나타냅니다 레이어를 샘플링하여 콘텐츠를 가져올 수 있지만 위치를 기준으로 셰이더가 인스턴스화된 maxSampleOffset 내에 머물러야 합니다
또한 SwiftUI는 색상과 같은 유형을 Metal에서 사용할 수 있는 표현으로 해결하고 변환합니다 여기에서 분홍색이 half4로 변환되었죠
Metal은 이러한 유형의 벡터를 많이 사용합니다 half4는 16비트 부동 소수점 숫자를 포함하며 4개의 구성 요소를 갖는 벡터입니다 이 유형은 색상의 빨강, 녹색, 파랑 및 알파 구성 요소를 인코딩하죠 마찬가지로 float2는 32비트 부동 소수점 숫자를 포함하며 2개의 구성 요소를 갖는 벡터로 2D 점이나 차원에 주로 사용됩니다
SwifUI에서 셰이더는 맞춤형 채우기와 3가지 효과에 사용될 수 있는데 색상 효과, 왜곡 효과, 레이어 효과입니다
세 가지 효과 중 레이어 효과가 가장 강력하고 다른 두 효과를 모두 포함하고 있으므로 레이어 효과를 작성하는 방법을 알려 드리겠습니다
현재 사용자가 보기를 탭할 때마다 트리거되는 푸시 효과가 보기에 적용되어 있습니다 보기는 spring을 통해 크기가 작아지다가 즉시 다시 올라옵니다 이는 제 상호작용에 대한 직접적인 피드백을 주지만 제가 탭하는 위치에서 애니메이션이 응답하지 않습니다 이러면 인위적이고 경직된 느낌을 주죠
대신 이러한 효과를 원하죠 보기를 누를 때마다 누른 위치에서부터 스케일 효과가 바깥쪽으로 퍼집니다 보기의 모든 픽셀에 대해 다른 영향을 줍니다 이제 SwiftUI 셰이더를 배웠으니 이러한 효과를 실제로 만들 수 있죠
이 효과를 구현하려면 Metal 파일에 새 셰이더 함수를 추가합니다 Ripple이라고 부를게요 레이어 효과의 API에 필요한 인수 2가지를 추가합니다 position과 Layer죠
각 픽셀의 출력을 설명하는 공식은 이미 계산해 두었습니다 보기가 눌린 지점, 경과된 시간 그리고 이 네 가지 매개변수에 대한 함수입니다
이 픽셀에 대한 왜곡을 계산하여 새로운 newPosition을 얻습니다 여기에서 보기를 샘플링합니다
왜곡의 강도에 따라 약간의 조정을 거친 후 수정된 색상을 반환합니다 다음으로 SwiftUI에서 이 셰이더 함수를 호출해야 합니다
이를 위해 RippleModifier라는 View Modifier를 만들어 셰이더 함수의 모든 매개변수를 SwiftUI에 노출합니다 body(content:) 메소드에서 셰이더를 인스턴스화하고 이를 콘텐츠에 적용합니다
셰이더에는 시간 개념이 없기 때문에 SwiftUI에서 애니메이션을 작동시켜야 합니다
방법은 다음과 같습니다 RippleEffect라는 ViewModifier를 또 작성했습니다 keyframeAnimator 보기 한정자를 사용하면 제스처와 같이 외부 변화를 기반으로 하는 애니메이션을 쉽게 실행할 수 있죠 트리거 값이 업데이트될 때마다 0에서 최종 지속 시간 값까지 elapsedTime에 애니메이션을 적용합니다 이렇게 하면 애니메이션의 모든 단계에서 현재 시간과 보기를 누른 원래 지점을 RippleModifier에 전달할 수 있습니다
잠시만요 앞서 보여 드린 네 가지 매개변수에 값을 할당하지 않았습니다 솔직히 말해서 어떤 값이 좋을지 전혀 모르겠네요 실험해 볼 수밖에 없네요, 그래서 이 디버그 UI를 빌드했습니다
RippleModifier가 모든 애니메이션을 자체적으로 수행하므로 애니메이션에서 대화식으로 앞뒤로 스크러빙할 수 있습니다 이렇게 하면 휴대폰이나 Xcode 미리보기에서 셰이더 기능에 적합한 매개변수를 입력할 수 있습니다
멋진 경험을 빌드하려면 많은 시행착오가 필요한데 디버그 UI는 복잡한 애니메이션을 반복할 때 유용합니다 이는 매개변수를 노출하거나 중간 값을 시각화하는 오버레이를 그리는 것을 뜻할 수 있습니다 이와 같이 피드백을 즉시 받으면 매우 유용하며 더 쉽고 빠르게 반복할 수 있죠 셰이더로 할 수 있는 작업이 무궁무진하기 때문에 이는 매우 중요합니다
셰이더로 애니메이션 채우기를 만들어 앱에 텍스처를 추가할 수 있습니다 셰이더와 TextRenderer를 함께 사용하면 텍스트에 왜곡을 적용하거나 독특한 사진 효과를 위한 그라디언트 맵을 만들 수 있습니다
이 동영상에서는 SwiftUI로 시각 효과를 만드는 다양한 방법을 살펴보았습니다 이러한 아이디어에 여러분만의 개성을 더해 보세요
맞춤형 스크롤 효과를 실험하여 돋보이는 앱을 만드세요 메시 그라디언트로 다채로운 색상을 추가하세요 앱에 맞춤형 보기 전환을 추가하세요 새로운 텍스트 렌더러 API로 생동감 넘치는 텍스트를 만드세요 Metal 셰이더로 새롭고 멋진 경험을 빌드하세요
이러한 도구를 활용하여 새로운 것을 만들어 보세요 시청해 주셔서 감사합니다
-
-
1:45 - Scroll view with pagination
ScrollView(.horizontal) { LazyHStack(spacing: 22) { ForEach(animals, id: \.self) { animal in AnimalPhoto(image: animal) } }.scrollTargetLayout() } .contentMargins(.horizontal, 44) .scrollTargetBehavior(.paging)
-
2:30 - Rotation effect
AnimalPhoto(image: animal) .scrollTransition( axis: .horizontal ) { content, phase in content .rotationEffect(.degrees(phase.value * 2.5)) .offset(y: phase.isIdentity ? 0 : 8) }
-
3:14 - Parallax Effect
ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(animals, id: \.self) { animal in VStack(spacing: 8) { ZStack { AnimalPhoto(image: animal) .scrollTransition( axis: .horizontal ) { content, phase in return content .offset(x: phase.value * -250) } } .containerRelativeFrame(.horizontal) .clipShape(RoundedRectangle(cornerRadius: 32)) } }.scrollTargetLayout() } .contentMargins(.horizontal, 32) .scrollTargetBehavior(.paging)
-
4:41 - Visual effect hue rotation
RoundedRectangle(cornerRadius: 24) .fill(.purple) .visualEffect({ content, proxy in content .hueRotation(Angle(degrees: proxy.frame(in: .global).origin.y / 10)) })
-
7:30 - Mesh gradient
MeshGradient( width: 3, height: 3, points: [ [0.0, 0.0], [0.5, 0.0], [1.0, 0.0], [0.0, 0.5], [0.9, 0.3], [1.0, 0.5], [0.0, 1.0], [0.5, 1.0], [1.0, 1.0] ], colors: [ .black,.black,.black, .blue, .blue, .blue, .green, .green, .green ] )
-
10:36 - Custom transition
struct Twirl: Transition { func body(content: Content, phase: TransitionPhase) -> some View { content .scaleEffect(phase.isIdentity ? 1 : 0.5) .opacity(phase.isIdentity ? 1 : 0) .blur(radius: phase.isIdentity ? 0 : 10) .rotationEffect( .degrees( phase == .willAppear ? 360 : phase == .didDisappear ? -360 : .zero ) ) .brightness(phase == .willAppear ? 1 : 0) } }
-
13:29 - The Minimum Viable TextRenderer
// The Minimum Viable TextRenderer struct AppearanceEffectRenderer: TextRenderer { func draw(layout: Text.Layout, in context: inout GraphicsContext) { for line in layout { context.draw(line) } } }
-
14:01 - A Custom Text Transition
import SwiftUI #Preview("Text Transition") { @Previewable @State var isVisible: Bool = true VStack { GroupBox { Toggle("Visible", isOn: $isVisible.animation()) } Spacer() if isVisible { let visualEffects = Text("Visual Effects") .customAttribute(EmphasisAttribute()) .foregroundStyle(.pink) .bold() Text("Build \(visualEffects) with SwiftUI 🧑💻") .font(.system(.title, design: .rounded, weight: .semibold)) .frame(width: 250) .transition(TextTransition()) } Spacer() } .multilineTextAlignment(.center) .padding() } struct EmphasisAttribute: TextAttribute {} /// A text renderer that animates its content. struct AppearanceEffectRenderer: TextRenderer, Animatable { /// The amount of time that passes from the start of the animation. /// Animatable. var elapsedTime: TimeInterval /// The amount of time the app spends animating an individual element. var elementDuration: TimeInterval /// The amount of time the entire animation takes. var totalDuration: TimeInterval var spring: Spring { .snappy(duration: elementDuration - 0.05, extraBounce: 0.4) } var animatableData: Double { get { elapsedTime } set { elapsedTime = newValue } } init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) { self.elapsedTime = min(elapsedTime, totalDuration) self.elementDuration = min(elementDuration, totalDuration) self.totalDuration = totalDuration } func draw(layout: Text.Layout, in context: inout GraphicsContext) { for run in layout.flattenedRuns { if run[EmphasisAttribute.self] != nil { let delay = elementDelay(count: run.count) for (index, slice) in run.enumerated() { // The time that the current element starts animating, // relative to the start of the animation. let timeOffset = TimeInterval(index) * delay // The amount of time that passes for the current element. let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration)) // Make a copy of the context so that individual slices // don't affect each other. var copy = context draw(slice, at: elementTime, in: ©) } } else { // Make a copy of the context so that individual slices // don't affect each other. var copy = context // Runs that don't have a tag of `EmphasisAttribute` quickly // fade in. copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2) copy.draw(run) } } } func draw(_ slice: Text.Layout.RunSlice, at time: TimeInterval, in context: inout GraphicsContext) { // Calculate a progress value in unit space for blur and // opacity, which derive from `UnitCurve`. let progress = time / elementDuration let opacity = UnitCurve.easeIn.value(at: 1.4 * progress) let blurRadius = slice.typographicBounds.rect.height / 16 * UnitCurve.easeIn.value(at: 1 - progress) // The y-translation derives from a spring, which requires a // time in seconds. let translationY = spring.value( fromValue: -slice.typographicBounds.descent, toValue: 0, initialVelocity: 0, time: time) context.translateBy(x: 0, y: translationY) context.addFilter(.blur(radius: blurRadius)) context.opacity = opacity context.draw(slice, options: .disablesSubpixelQuantization) } /// Calculates how much time passes between the start of two consecutive /// element animations. /// /// For example, if there's a total duration of 1 s and an element /// duration of 0.5 s, the delay for two elements is 0.5 s. /// The first element starts at 0 s, and the second element starts at 0.5 s /// and finishes at 1 s. /// /// However, to animate three elements in the same duration, /// the delay is 0.25 s, with the elements starting at 0.0 s, 0.25 s, /// and 0.5 s, respectively. func elementDelay(count: Int) -> TimeInterval { let count = TimeInterval(count) let remainingTime = totalDuration - count * elementDuration return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count) } } extension Text.Layout { /// A helper function for easier access to all runs in a layout. var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> { self.flatMap { line in line } } /// A helper function for easier access to all run slices in a layout. var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> { flattenedRuns.flatMap(\.self) } } struct TextTransition: Transition { static var properties: TransitionProperties { TransitionProperties(hasMotion: true) } func body(content: Content, phase: TransitionPhase) -> some View { let duration = 0.9 let elapsedTime = phase.isIdentity ? duration : 0 let renderer = AppearanceEffectRenderer( elapsedTime: elapsedTime, totalDuration: duration ) content.transaction { transaction in // Force the animation of `elapsedTime` to pace linearly and // drive per-glyph springs based on its value. if !transaction.disablesAnimations { transaction.animation = .linear(duration: duration) } } body: { view in view.textRenderer(renderer) } } }
-
22:55 - A simple ripple effect Metal shader
// Insert #include <metal_stdlib> #include <SwiftUI/SwiftUI.h> using namespace metal; [[ stitchable ]] half4 Ripple( float2 position, SwiftUI::Layer layer, float2 origin, float time, float amplitude, float frequency, float decay, float speed ) { // The distance of the current pixel position from `origin`. float distance = length(position - origin); // The amount of time it takes for the ripple to arrive at the current pixel position. float delay = distance / speed; // Adjust for delay, clamp to 0. time -= delay; time = max(0.0, time); // The ripple is a sine wave that Metal scales by an exponential decay // function. float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time); // A vector of length `amplitude` that points away from position. float2 n = normalize(position - origin); // Scale `n` by the ripple amount at the current pixel position and add it // to the current pixel position. // // This new position moves toward or away from `origin` based on the // sign and magnitude of `rippleAmount`. float2 newPosition = position + rippleAmount * n; // Sample the layer at the new position. half4 color = layer.sample(newPosition); // Lighten or darken the color based on the ripple amount and its alpha // component. color.rgb += 0.3 * (rippleAmount / amplitude) * color.a; return color; }
-
23:36 - A Custom Ripple Effect
import SwiftUI #Preview("Ripple") { @Previewable @State var counter: Int = 0 @Previewable @State var origin: CGPoint = .zero VStack { Spacer() Image("palm_tree") .resizable() .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .onPressingChanged { point in if let point { origin = point counter += 1 } } .modifier(RippleEffect(at: origin, trigger: counter)) .shadow(radius: 3, y: 2) Spacer() } .padding() } #Preview("Ripple Editor") { @Previewable @State var origin: CGPoint = .zero @Previewable @State var time: TimeInterval = 0.3 @Previewable @State var amplitude: TimeInterval = 12 @Previewable @State var frequency: TimeInterval = 15 @Previewable @State var decay: TimeInterval = 8 VStack { GroupBox { Grid { GridRow { VStack(spacing: 4) { Text("Time") Slider(value: $time, in: 0 ... 2) } VStack(spacing: 4) { Text("Amplitude") Slider(value: $amplitude, in: 0 ... 100) } } GridRow { VStack(spacing: 4) { Text("Frequency") Slider(value: $frequency, in: 0 ... 30) } VStack(spacing: 4) { Text("Decay") Slider(value: $decay, in: 0 ... 20) } } } .font(.subheadline) } Spacer() Image("palm_tree") .resizable() .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .modifier(RippleModifier(origin: origin, elapsedTime: time, duration: 2, amplitude: amplitude, frequency: frequency, decay: decay)) .shadow(radius: 3, y: 2) .onTapGesture { origin = $0 } Spacer() } .padding(.horizontal) } struct PushEffect<T: Equatable>: ViewModifier { var trigger: T func body(content: Content) -> some View { content.keyframeAnimator( initialValue: 1.0, trigger: trigger ) { view, value in view.visualEffect { view, _ in view.scaleEffect(value) } } keyframes: { _ in SpringKeyframe(0.95, duration: 0.2, spring: .snappy) SpringKeyframe(1.0, duration: 0.2, spring: .bouncy) } } } /// A modifer that performs a ripple effect to its content whenever its /// trigger value changes. struct RippleEffect<T: Equatable>: ViewModifier { var origin: CGPoint var trigger: T init(at origin: CGPoint, trigger: T) { self.origin = origin self.trigger = trigger } func body(content: Content) -> some View { let origin = origin let duration = duration content.keyframeAnimator( initialValue: 0, trigger: trigger ) { view, elapsedTime in view.modifier(RippleModifier( origin: origin, elapsedTime: elapsedTime, duration: duration )) } keyframes: { _ in MoveKeyframe(0) LinearKeyframe(duration, duration: duration) } } var duration: TimeInterval { 3 } } /// A modifier that applies a ripple effect to its content. struct RippleModifier: ViewModifier { var origin: CGPoint var elapsedTime: TimeInterval var duration: TimeInterval var amplitude: Double = 12 var frequency: Double = 15 var decay: Double = 8 var speed: Double = 1200 func body(content: Content) -> some View { let shader = ShaderLibrary.Ripple( .float2(origin), .float(elapsedTime), // Parameters .float(amplitude), .float(frequency), .float(decay), .float(speed) ) let maxSampleOffset = maxSampleOffset let elapsedTime = elapsedTime let duration = duration content.visualEffect { view, _ in view.layerEffect( shader, maxSampleOffset: maxSampleOffset, isEnabled: 0 < elapsedTime && elapsedTime < duration ) } } var maxSampleOffset: CGSize { CGSize(width: amplitude, height: amplitude) } } extension View { func onPressingChanged(_ action: @escaping (CGPoint?) -> Void) -> some View { modifier(SpatialPressingGestureModifier(action: action)) } } struct SpatialPressingGestureModifier: ViewModifier { var onPressingChanged: (CGPoint?) -> Void @State var currentLocation: CGPoint? init(action: @escaping (CGPoint?) -> Void) { self.onPressingChanged = action } func body(content: Content) -> some View { let gesture = SpatialPressingGesture(location: $currentLocation) content .gesture(gesture) .onChange(of: currentLocation, initial: false) { _, location in onPressingChanged(location) } } } struct SpatialPressingGesture: UIGestureRecognizerRepresentable { final class Coordinator: NSObject, UIGestureRecognizerDelegate { @objc func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer ) -> Bool { true } } @Binding var location: CGPoint? func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { let recognizer = UILongPressGestureRecognizer() recognizer.minimumPressDuration = 0 recognizer.delegate = context.coordinator return recognizer } func handleUIGestureRecognizerAction( _ recognizer: UIGestureRecognizerType, context: Context) { switch recognizer.state { case .began: location = context.converter.localLocation case .ended, .cancelled, .failed: location = nil default: break } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.